用函数指针替换 if 语句有什么缺点?

What is the downside of replacing if statements with function pointers?

提问人:ingotangjingle 提问时间:9/10/2023 更新时间:9/10/2023 访问量:81

问:

我正在寻找优化我的更新循环,并绕过了这段代码:

// Define two lambda functions
auto iftrue = [](int x, int y) -> int {
    return x;
};

auto iffalse = [](int x, int y) -> int {
    return y;
};

// Define a pointer to a function that takes two integers and returns an integer
typedef int (*FunctionPointer)(int, int);

FunctionPointer ptr = nullptr;

void SetFalse()
{
    ptr = iffalse;
}
void SetTrue()
{
    ptr = iftrue;
}

int main() {
    int a = 5, b = 3;

    SetTrue();

    // Use the second lambda function
    int result = ptr(a, b);
    std::cout << "Result: " << result << std::endl;
    
    SetFalse();
    
    // Use the second lambda function
    result = ptr(a, b);
    std::cout << "Result: " << result << std::endl;

    return 0;
}

它击中了我,这难道不是总是比 if 语句运行得更好吗?

我检查了速度,嗯......https://onlinegdb.com/mCZc6Eb7Z

似乎功能更快。在可能的情况下,不应该将所有内容都更改为函数指针吗?

C++ if 语句 优化 函数指针

评论

3赞 Alan Birtles 9/10/2023
编译器太聪明了,它完全 godbolt.org/z/jT5KWTPoe 优化了函数调用。在大多数情况下,函数调用可能比函数调用的成本高得多,当分支预测器失败并且管道被刷新时,确实会产生性能损失,但调用函数涉及更多,它必须将参数和寄存器推送到堆栈上ifif
2赞 273K 9/10/2023
使用 quick-bench.com 来找出答案。
1赞 Jérôme Richard 9/10/2023
条件往往比依赖于数据的函数调用更容易预测。现代处理器具有条件的分支预测器,如果正确预测,条件是便宜的。如果处理器不知道,它会使用静态预测,有 50% 的机会快速,没有过去的信息。函数调用总是很慢,尤其是第一次调用,因为难以预测的跳转(这往往会导致大的管道停顿)。简而言之:保持条件。这在大多数平台上更简单、更易于阅读,当然也更快,尤其是未来的平台。

答:

1赞 Ben A. 9/10/2023 #1

简短的回答是,在处理代码时,编译器会尝试预测语句的结果以进行优化。但是,如果预测不正确,则成本可能会更高。if

对于函数指针,您可以删除执行路径的条件部分,因此不会发生上述情况,并且在某些情况下,代码会运行得更快。话虽如此,这里的这种速度优势发生在少数情况下。

因此,最终,您的问题的答案是分支预测

评论

0赞 Alan Birtles 9/10/2023
我无法想象函数调用比失败的分支预测便宜,在给定的示例中没有函数调用
0赞 Paul Sanders 9/10/2023
分支预测是一个运行时的东西,与编译器无关(尽管它可能会尝试优化它评估测试的顺序)。
1赞 Alan Birtles 9/10/2023 #2

编译器太聪明了,它完全 https://godbolt.org/z/jT5KWTPoe 优化了函数调用。这种微基准测试可能很难实现,现代编译器可能会走很多捷径并扭曲您的结果。

使用 quick-bench,我们可以看到当编译器无法优化函数调用时,它比函数指针快 3 倍:https://quick-bench.com/q/3-9AgNCJN1MwiD2dkJ-FttbGVWYif

语句可能很慢,因为 CPU 必须猜测分支将走哪条路,如果它出错了,那么它将推测性地执行了错误的指令,然后必须放弃此执行并从分支点重新开始计算。if

但是,函数调用可能比失败的分支预测更昂贵。要调用函数,您需要在堆栈上保存各种寄存器,执行分支(CPU 可能无法预测),运行该函数,然后从堆栈中恢复寄存器。

如果在两组功能之间切换,交换函数指针可能比重复执行便宜,但这是分支预测器的理想情况,当总是采用相同的分支时,因此任何好处都可能是最小的。if

1赞 Jan Schultke 9/10/2023 #3

首先,你的测试方法有很大的缺陷。如果您想获得有意义的结果,您应该使用诸如 quick-bench 之类的网站。

其次,函数指针没有得到编译器的很好的优化。 它们并不总是很好地内联和简化。

函数指针可以更快是否合理

让我们比较一下三种实现:

int choose_if(bool c, int x, int y) {
    if (c) return x; // most naive version using if statements
    else   return y;
}

int choose_ptr(bool c, int x, int y) {
    static constexpr int(*selectors[2])(int, int) = { // table of two function pointers
        [](int a, int b) { return a; },
        [](int a, int b) { return b; }
    };
    return selectors[c](x, y); // choose a function pointer and call it; no branches
}

int (*selector)(int, int);

int choose_ptr(int x, int y) {
    return selector(x, y); // let's assume that we've made the previous decision
                           // in advance, and just have to call the pointer
}

这为我们提供了以下输出 (https://godbolt.org/z/bd1ceb9s8):

choose_if(bool, int, int):                        # @choose_if(bool, int, int)
        mov     eax, esi
        test    edi, edi
        cmove   eax, edx
        ret
choose_ptr(bool, int, int):                      # @choose_ptr(bool, int, int)
        mov     eax, edi
        lea     rcx, [rip + choose_ptr(bool, int, int)::selectors]
        mov     edi, esi
        mov     esi, edx
        jmp     qword ptr [rcx + 8*rax]         # TAILCALL
choose_ptr(int, int):                       # @choose_ptr(int, int)
        mov     rax, qword ptr [rip + selector]
        jmp     rax                             # TAILCALL

choose_if是最幼稚的,也是最朴素的。 只有两个周期的延迟。 需要更多的工作:它访问内存并进行间接调用,这两者都可能很昂贵。它没有优化到上面的简单版本,即使这在技术上是可行的。 只是一个间接(尾部)调用。cmovechoose_ptrcmovechoose_ptr

在某些体系结构上,调用函数指针可以击败 if 语句,这在原则上是合理的,尤其是在没有分支预测形式的非常简单的 CPU 上。但是,由于函数指针的优化不佳,并且由于 等无分支指令,这在x86_64上极不可能。cmov

还要记住,访问内存比使用寄存器更潜在、更昂贵,并且函数指针的大多数使用都会迫使您访问内存。

基准

enter image description here

这个极其幼稚的基准显然不能代表 if 语句与函数指针的整体关系,但证实了这三个函数的性能与直觉相符。

评论

0赞 ingotangjingle 9/10/2023
我们等待输入在两个函数指针之间切换的循环呢?您如何将其纳入基准测试?我认为随着时间的推移,这些分支会得到更大的影响?
0赞 Jan Schultke 9/10/2023
如果等待输入在两个函数指针之间进行选择,则实质上是 .如果您决定只选择一次哪个函数指针,则它是 .无论如何,对此进行基准测试有点困难,因为它不是对数字运算进行基准测试,而是控制流,而控制流基准只有在更大的程序上下文中才有意义。boolChoosePtrChoosePtrPrecomputed
0赞 Jan Schultke 9/10/2023
此外,如果循环中涉及任何形式的用户输入,那么这将超过使用 if 语句或函数指针可能产生的任何成本。在这种情况下,你只需编写最简单的语句,即 if 语句。
0赞 Alan Birtles 9/10/2023
@ingotangjingle,如果你几乎总是在 if 语句中使用相同的分支,这是分支预测器的理想情况,那么只有当分支预测器失败时,分支才会昂贵