虚拟机的 C++ For 循环优化
C++ For loop optimizations for a virtual machine
上下文
我的问题是双重的(实际上是两个问题)但很基础*。但首先,我将针对某些上下文展示一些相关代码。对于 TL;DR 'meat and potatoes',请跳至实际问题的底部。
*(我假设回答者在尝试回答之前知道 happening/how 虚拟机从根本上运行的是什么)。
如前所述,我正在编写一个(玩具)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
宏,它在调用退出(退出 for 循环)之前检查调用是否为空,只是因为它是正在执行的最后一条指令(因为最后一条指令我们可以成为函数的一部分,甚至是 return).此外, ip
(指令指针) 只是一个无符号长整型 (u64), _PROGRAM_SIZE
(大小以字节为单位的程序)。 <em>instr</em>
是一个字节并且是对当前指令的引用(1字节).
肉和土豆
问题 1:由于我正在为每个 block/case 初始化两个可变大小的新整数(分割成块以避免重新声明错误等),将声明它们在 for
循环之上对速度、分配延迟、程序大小等方面有帮助吗?
问题2:在这种情况下continue
会比break
快吗,有没有更快的方法来执行这样的条件循环,比如作为某种类似于 this post 的 goto-pointer-to-label ,它与实现无关,或者以某种方式避免 continue
或 break
?
的成本
总而言之,我的优先级是速度,然后是内存成本(速度、效率),然后是文件大小(虚拟机)。
对于问题 2,处理器应该能够相当好地处理中断,因为它实际上是一个总是会出现在汇编中的分支,所以它不应该引起太多问题。这应该意味着没有流水线冲洗,原因是分支预测单元应该正确处理。我相信上面已经回答了问题 1。
在回答具体问题之前,请注意:没有任何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 编译器资源管理器轻松检查为 first and second 变体生成的程序集。它们是相同的 - 这是循环:
.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" 这样的指令。您实际上只有跳转,无论是否有条件,基本上都是 goto。 break 和 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;
}
}
}
它使用中断存在的情况。这是 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
这里编译选择了跳转table方式。 num 的值用于查找跳转地址(table 是一系列.quad
指令),然后间接跳转到标签L4 到L8 之一。中断变为 jmp .L3
,执行循环逻辑。
请注意,跳转 table 不是编译开关的唯一方法 - 如果我使用 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 - 针对 this 特定的编译器和目标。 continue 语句和 break 语句意味着完全相同的控制流,所以我希望对于大多数打开优化的体面编译器来说都是如此。
上下文
我的问题是双重的(实际上是两个问题)但很基础*。但首先,我将针对某些上下文展示一些相关代码。对于 TL;DR 'meat and potatoes',请跳至实际问题的底部。
*(我假设回答者在尝试回答之前知道 happening/how 虚拟机从根本上运行的是什么)。
如前所述,我正在编写一个(玩具)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
宏,它在调用退出(退出 for 循环)之前检查调用是否为空,只是因为它是正在执行的最后一条指令(因为最后一条指令我们可以成为函数的一部分,甚至是 return).此外, ip
(指令指针) 只是一个无符号长整型 (u64), _PROGRAM_SIZE
(大小以字节为单位的程序)。 <em>instr</em>
是一个字节并且是对当前指令的引用(1字节).
肉和土豆
问题 1:由于我正在为每个 block/case 初始化两个可变大小的新整数(分割成块以避免重新声明错误等),将声明它们在 for
循环之上对速度、分配延迟、程序大小等方面有帮助吗?
问题2:在这种情况下continue
会比break
快吗,有没有更快的方法来执行这样的条件循环,比如作为某种类似于 this post 的 goto-pointer-to-label ,它与实现无关,或者以某种方式避免 continue
或 break
?
总而言之,我的优先级是速度,然后是内存成本(速度、效率),然后是文件大小(虚拟机)。
对于问题 2,处理器应该能够相当好地处理中断,因为它实际上是一个总是会出现在汇编中的分支,所以它不应该引起太多问题。这应该意味着没有流水线冲洗,原因是分支预测单元应该正确处理。我相信上面已经回答了问题 1。
在回答具体问题之前,请注意:没有任何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 编译器资源管理器轻松检查为 first and second 变体生成的程序集。它们是相同的 - 这是循环:
.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" 这样的指令。您实际上只有跳转,无论是否有条件,基本上都是 goto。 break 和 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;
}
}
}
它使用中断存在的情况。这是 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
这里编译选择了跳转table方式。 num 的值用于查找跳转地址(table 是一系列.quad
指令),然后间接跳转到标签L4 到L8 之一。中断变为 jmp .L3
,执行循环逻辑。
请注意,跳转 table 不是编译开关的唯一方法 - 如果我使用 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 - 针对 this 特定的编译器和目标。 continue 语句和 break 语句意味着完全相同的控制流,所以我希望对于大多数打开优化的体面编译器来说都是如此。