【发布时间】:2020-03-05 12:51:15
【问题描述】:
在您投反对票或开始说gotoing 是邪恶的和过时的之前,请阅读为什么它在这种情况下可行的理由。在将其标记为重复之前,请阅读完整的问题。
当I stumbled across computed gotos 时,我正在阅读有关虚拟机解释器的信息。显然,它们可以显着提高某些代码的性能。最著名的例子是主 VM 解释器循环。
考虑这样一个(非常)简单的虚拟机:
#include <iostream>
enum class Opcode
{
HALT,
INC,
DEC,
BIT_LEFT,
BIT_RIGHT,
RET
};
int main()
{
Opcode program[] = { // an example program that returns 10
Opcode::INC,
Opcode::BIT_LEFT,
Opcode::BIT_LEFT,
Opcode::BIT_LEFT,
Opcode::INC,
Opcode::INC,
Opcode::RET
};
int result = 0;
for (Opcode instruction : program)
{
switch (instruction)
{
case Opcode::HALT:
break;
case Opcode::INC:
++result;
break;
case Opcode::DEC:
--result;
break;
case Opcode::BIT_LEFT:
result <<= 1;
break;
case Opcode::BIT_RIGHT:
result >>= 1;
break;
case Opcode::RET:
std::cout << result;
return 0;
}
}
}
这个虚拟机所能做的就是对一个类型的int 进行一些简单的操作并打印它。尽管它的用处值得怀疑,但它仍然说明了这个主题。
VM 的关键部分显然是for 循环中的switch 语句。它的性能由许多因素决定,其中最重要的肯定是branch prediction 和跳转到适当执行点的动作(case 标签)。
这里有优化的空间。为了加快这个循环的执行速度,可以使用所谓的computed gotos。
计算的 Gotos
Computed goto 是 Fortran 程序员和使用特定(非标准)GCC 扩展的程序员所熟知的结构。我不赞成使用任何非标准的、实现定义的和(显然)未定义的行为。但是为了说明这个概念,我将使用上面提到的 GCC 扩展的语法。
在标准 C++ 中,我们可以定义稍后可以通过 goto 语句跳转到的标签:
goto some_label;
some_label:
do_something();
这样做不被认为是好的代码 (and for a good reason!)。尽管反对使用goto(其中大多数与代码可维护性有关)有很好的论据,但这个令人讨厌的特性有一个应用程序。是性能的提升。
Using a goto statement can be faster than a function invocation. 这是因为“文书工作”的数量,例如设置堆栈和返回值,在调用函数时必须完成。同时goto 有时可以转换为单个jmp 汇编指令。
为了充分发挥goto 的潜力,对GCC 编译器进行了扩展,使goto 更加动态。也就是说,跳转到的标签可以在运行时确定。
这个扩展允许人们获得一个标签指针,类似于一个函数指针和goto指向它:
void* label_ptr = &&some_label;
goto (*label_ptr);
some_label:
do_something();
这是一个有趣的概念,它使我们能够进一步增强我们的简单 VM。我们将使用标签指针数组(所谓的跳转表)而不是使用switch 语句,而不是使用goto 指向适当的指针(操作码将用于索引数组) :
// [Courtesy of Eli Bendersky][4]
// This code is licensed with the [Unlicense][5]
int interp_cgoto(unsigned char* code, int initval) {
/* The indices of labels in the dispatch_table are the relevant opcodes
*/
static void* dispatch_table[] = {
&&do_halt, &&do_inc, &&do_dec, &&do_mul2,
&&do_div2, &&do_add7, &&do_neg};
#define DISPATCH() goto *dispatch_table[code[pc++]]
int pc = 0;
int val = initval;
DISPATCH();
while (1) {
do_halt:
return val;
do_inc:
val++;
DISPATCH();
do_dec:
val--;
DISPATCH();
do_mul2:
val *= 2;
DISPATCH();
do_div2:
val /= 2;
DISPATCH();
do_add7:
val += 7;
DISPATCH();
do_neg:
val = -val;
DISPATCH();
}
}
这个版本比使用switch 的版本快大约 25%(链接的博客文章中的那个,而不是上面的那个)。这是因为每次操作后只执行一次跳转,而不是两次。
switch 的控制流:
例如,如果我们想执行Opcode::FOO 然后Opcode::SOMETHING,它看起来像这样:
如您所见,在执行一条指令后会执行两次跳转。第一个是回到switch 代码,第二个是实际指令。
相反,如果我们使用一组标签指针(提醒一下,它们是非标准的),我们将只有一次跳转:
值得注意的是,除了通过减少操作来节省循环之外,我们还通过消除额外的跳转来提高分支预测的质量。
现在,我们知道,通过使用标签指针数组而不是 switch,我们可以显着提高 VM 的性能(大约 20%)。我想也许这也可以有一些其他的应用程序。
我得出的结论是,这种技术可以用于任何具有循环的程序中,在该循环中,它会顺序地间接调度某些逻辑。一个简单的例子(除了 VM)可以在多态对象容器的每个元素上调用 virtual 方法:
std::vector<Base*> objects;
objects = get_objects();
for (auto object : objects)
{
object->foo();
}
现在,它有更多的应用。
但是有一个问题:在标准 C++ 中没有诸如标签指针之类的东西。因此,问题是:有没有一种方法可以模拟标准 C++ 中计算的gotos 的行为在性能上可以匹配它们?。
编辑 1:
使用开关还有另一个缺点。 user1937198 提醒了我。它是绑定检查。简而言之,它检查switch 内的变量值是否与cases 中的任何一个匹配。它添加了冗余分支(此检查是标准强制要求的)。
编辑 2:
In response to cmaster,我将阐明我对减少虚函数调用开销的想法。一个肮脏的方法是在每个派生实例中都有一个表示其类型的 id,该 id 将用于索引跳转表(标签指针数组)。问题是:
- 标准 C++ 没有跳转表
- 添加新的派生类时需要修改所有跳转表。
如果有人想出某种类型的模板魔法(或作为最后手段的宏),我将不胜感激,这将允许将其编写得更简洁、可扩展和自动化,如下所示:
【问题讨论】:
-
我记得有人告诉我
switches 是在下面的gotos 方面实现的,所以对我来说这是没有意义的。但我无法验证。这是我能为这次谈话做出的唯一富有成效的事情。 -
您正在测试哪个编译器和优化级别? clang++ 9.0 将您的 switch 示例编译为跳转表,并额外检查是否满足分支,不检查是否添加内置无法访问的默认值:gcc.godbolt.org/z/ywDqmK
-
我只是在等待模板向导提出解决方案 ;-) 老实说,计算 goto 的主要问题是,输入通常表现不佳:A VM为软件仿真定义的通常使用最多 256 种不同的 OP 代码,对调度表大小进行严格限制。但是一般的调度,就像在 C++ 中使用 v-tables 所做的那样,并没有提供这种奢侈。 v-tables(= 类 ID)几乎可以在内存中的任何位置,因此您无法为它们创建调度表。也就是说,v-table 是一种计算的 goto 形式(+ 函数调用开销)。
-
顺便说一句,这个技巧有一个版本没有表格,通过实际计算地址而不是查找它(需要一些填充)。
-
@YanB。 this question 中使用了一个特例版本,我无法找到一个好的参考,但我猜这是“装配民间传说中的已知技术”?你也可能喜欢this
标签: c++ optimization virtual-functions goto branch-prediction