【问题标题】:How to speed up dynamic dispatch by 20% using computed gotos in standard C++如何使用标准 C++ 中的计算 goto 将动态调度速度提高 20%
【发布时间】: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 将用于索引跳转表(标签指针数组)。问题是:

  1. 标准 C++ 没有跳转表
  2. 添加新的派生类时需要修改所有跳转表。

如果有人想出某种类型的模板魔法(或作为最后手段的宏),我将不胜感激,这将允许将其编写得更简洁、可扩展和自动化,如下所示:

【问题讨论】:

  • 我记得有人告诉我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


【解决方案1】:

在 MSVC 的最新版本中,关键是为优化器提供所需的提示,以便它可以判断仅对跳转表进行索引是一种安全的转换。原始代码有两个限制可以防止这种情况发生,从而使对计算标签代码生成的代码的优化成为无效转换。

首先在原始代码中,如果程序计数器溢出程序,则循环退出。在计算的标签代码中,调用未定义的行为(取消引用超出范围的索引)。因此编译器必须为此插入一个检查,导致它为循环头生成一个基本块,而不是在每个 switch 块中内联它。

其次,在原始代码中,默认情况不处理。虽然开关覆盖了所有枚举值,因此没有分支匹配是未定义的行为,但 msvc 优化器没有足够的智能来利用它,因此会生成一个什么都不做的默认情况。检查这个默认情况需要一个条件,因为它处理大范围的值。在这种情况下,计算的 goto 代码也会调用未定义的行为。

第一个问题的解决方案很简单。不要使用 c++ 范围 for 循环,使用 while 循环或无条件的 for 循环。不幸的是,第二个解决方案需要特定于平台的代码来告诉优化器默认为_assume(0) 形式的未定义行为,但在大多数编译器中都存在类似的东西(clang 和 gcc 中的__builtin_unreachable()),并且可以有条件地编译当没有任何正确性问题的情况下不存在等价物时,什么都没有。

所以这样的结果是:

#include <iostream>

enum class Opcode
{
    HALT,
    INC,
    DEC,
    BIT_LEFT,
    BIT_RIGHT,
    RET
};

int run(Opcode* program) {
    int result = 0;
    for (int i = 0; true;i++)
    {
        auto instruction = program[i];
        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;
        default:
            __assume(0);
        }
    }
}

生成的程序集可以在godbolt上验证

【讨论】:

  • s/HALT/NOOP/ ;-)
  • @Jarod42 只是复制原始代码中的命名,这似乎与跳转表的问题略有正交。
  • 感谢您的回答。在最初的基准测试之后,它似乎确实将性能提高到了一个与计算 goto 有一定误差的点。如果您展示了在循环中调用虚函数的普遍问题的解决方案,我仍然会很感激(开关是这个问题的一个特定情况 - 每个操作码都可以是一个具有从公共基础抽象类继承的虚函数的对象)。
  • 顺便说一下,NOOP 确实是 HALT 的更好名称。 ;)
  • @YanB。广义的情况要困难得多。虚函数调用是一个开放集而不是开关的封闭集这一事实意味着优化器无法构建跳转表。
猜你喜欢
  • 2017-06-21
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2011-11-02
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多