上下文
<小时/>
我的问题是双重的(实际上是两个问题),但非常基本*。但首先,我将针对某些背景显示一些相关代码。对于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,与实现无关,或以某种方式避免continue
或break
的费用?
总结一下,我的优先级是速度,然后内存成本(速度,效率),然后文件大小(VM的优先级)。
答案 0 :(得分:3)
在回答具体问题之前,请注意:没有任何CPU直接执行C ++。因此,语言级别的此类微优化问题在很大程度上取决于编译器,软件运行时环境和目标硬件。完全有可能一种技术在你今天使用的编译器上运行得更好,但在你明天使用的编译器上更糟糕。类似于硬件选择,如CPU架构。
获得明确答案的唯一方法是在现实情况下对其进行基准测试,而了解基准测试结果的唯一方法通常是深入了解生成的程序集。如果这种优化对您很重要,请考虑学习一下有关开发体系结构的汇编语言。
鉴于此,我将选择一个特定的编译器(gcc)和一个通用架构(x86)并在该上下文中回答。对于其他选择,细节会略有不同,但我希望对于任何体面的编译器和硬件组合,广泛的笔划都是相似的。
申报地点无关紧要。声明本身甚至没有真正变成代码 - 它只是生成代码的定义和用法。
例如,考虑下面一个简单循环的两个变体(外部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和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
基本上声明不会生成任何代码 - 只有赋值才会生成。
这里需要注意的是,在硬件级别,没有“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已在上面得到解答。