函数指针会使程序变慢吗?

72

我了解了C语言中的函数指针。 而且每个人都说这会使我的程序运行变慢。 这是真的吗?

我编写了一个程序来验证它。 在两种情况下我得到了相同的结果。(测量时间。)

那么,使用函数指针是不好的吗? 提前感谢。

回应一些人的问题。 我之所以说“运行变慢”,是因为我在循环中进行了比较。就像这样:

int end = 1000;
int i = 0;

while (i < end) {
 fp = func;
 fp ();
}
当你执行这个代码时,我得到的结果与我执行它时相同。
while (i < end) {
 func ();
}

因此,我认为函数指针在时间上没有任何区别,并且并不会像许多人所说的那样使程序运行缓慢。


2
当你说运行缓慢时,你是和什么进行比较呢?一切都是相对的。 - stefanB
16
您的程序不能有效地测量静态函数调用和通过函数指针调用之间的差异。编译器会在您的示例中将调用替换为静态调用,优化器在编译时知道目标并将删除间接调用。 - Patrick Schlüter
继续@tristopia的评论,为了进行有效的比较,请选择一个接受函数指针和函数对象的函数,例如排序程序,例如std::sort,并比较两种方式的结果。 - Arun
8
它们需要相同时间运行,因为它们两个都是无限循环! - user3160514
8个回答

109

对于实际性能敏感的情况,比如在循环中多次调用函数,性能可能完全相同。这可能听起来很奇怪,因为人们习惯将 C 代码视为由一个抽象的 C 机器执行的,这个“机器语言”与 C 语言本身非常相似。在这种情况下,“默认情况下”,与直接调用相比,间接调用函数的确要慢一些,因为它需要形式上进行额外的内存访问以确定调用目标。

然而,在现实生活中,代码是由真正的计算机执行并由优化编译器编译的,优化编译器具有对底层机器架构的相当好的了解,从而帮助它为那个具体的机器生成最优代码。在许多平台上,执行循环中的函数调用的最有效方法实际上可能导致直接和间接调用的相同代码,从而导致两者具有相同的性能。

例如,考虑 x86 平台。如果我们将直接调用和间接调用“字面上”转换为机器代码,我们可能会得到像这样的结果:

// Direct call
do-it-many-times
  call 0x12345678

// Indirect call
do-it-many-times
  call dword ptr [0x67890ABC]

前者在机器指令中使用立即操作数,通常比后者更快,因为后者需要从某个独立的内存位置读取数据。

此时让我们记住,x86架构实际上还有一种方法可以向call指令提供操作数。它是在寄存器中提供目标地址。这种格式非常重要的一点是,它通常比以上两种方式都要。这对我们意味着什么?这意味着一个优化良好的编译器必须并且将会利用这一事实。为了实现上述循环,编译器将尝试在两种情况下都使用通过寄存器调用的方式。如果成功,最终代码可能如下所示:

// Direct call

mov eax, 0x12345678

do-it-many-times
  call eax

// Indirect call

mov eax, dword ptr [0x67890ABC]

do-it-many-times
  call eax
请注意,现在最重要的部分——循环体中实际调用的部分——在两种情况下都是精确相同的。不用说,性能将是几乎相同的。
甚至可以说,无论听起来多么奇怪,在这个平台上,一个直接调用(使用 call 中的立即操作数进行调用)比一个间接调用更慢,只要间接调用的操作数以寄存器的形式提供(而不是存储在内存中)。
当然,在一般情况下,整个过程并不那么简单。编译器必须处理有限的寄存器可用性、别名问题等。但是,在您的示例中这样简单的情况(甚至在更复杂的情况下),好的编译器都会执行以上优化,并完全消除循环直接调用和循环间接调用之间的任何性能差异。在C++中调用虚函数时,这种优化特别有效,因为在典型实现中涉及的指针完全由编译器控制,使其完全了解别名图片和其他相关内容。
当然,总会有一个问题,那就是你的编译器是否足够聪明以优化这样的事情...

如何看待函数调用的内联可能性?我认为直接调用的内联可能性比间接调用略高。 - Nawaz
2
这是无稽之谈。编译器不会将直接的call转换为寄存器间接的call(使用像ebx而不是eax这样的调用保留寄存器)。在正确预测的情况下,call rel32与其一样快,具有更低的错误预测惩罚,并且可能消耗更少的分支预测资源。Agner Fog的优化指南和Intel的优化手册(x86标签wiki中的链接)都没有提到这种技术,事实上编译器尽可能地反虚拟化(与此相反),即使它们选择不进行内联。 - Peter Cordes
1
你只有在需要对一个函数多次调用时,才会选择使用 call reg。这种情况下,为了优化代码大小,可以从一个函数中多次调用帮助函数。更短的x86 call指令 - Peter Cordes
即使你的说法是正确的(正如Peter Cordes所指出的那样),关键是程序运行得更快还是更慢,而不是call指令。如果内存超出缓存怎么办?显然,200个周期的滞后和流水线惩罚会使任何在这个答案中提到的东西相形见绌,不是吗? - SO_fix_the_vote_sorting_bug

31

我认为当人们说这句话时,他们指的是使用函数指针可能会阻止编译器优化(内联)和处理器优化(分支预测)。然而,如果函数指针是实现你想要做的事情的有效方法,那么任何其他方法都会有相同的缺点。

除非你的函数指针在性能关键应用程序中的紧密循环中使用或在非常慢的嵌入式系统上使用,否则差异很小。


1
在紧密循环中至少有一个函数指针会预测得很好。然而,不进行内联的成本可能很高,特别是如果函数很小,具有多个参数,并且/或通过引用传递/返回任何内容。 - Peter Cordes

15

大多数情况下这种说法是不正确的。首先,如果不使用函数指针的替代方案是什么

if (condition1) {
        func1();
} else if (condition2)
        func2();
} else if (condition3)
        func3();
} else {
        func4();
}

这很可能比仅使用单个函数指针要慢得多。虽然通过指针调用函数确实会带来一些(通常可以忽略的)开销,但通常并不是直接调用与通过指针调用之间的差异相关。

第二点,决不要在没有任何测量的情况下为性能进行优化。知道瓶颈在哪里非常困难(读起来几乎不可能),有时这可能会相当反直觉(例如Linux内核开发人员已经开始从函数中删除inline关键字,因为它实际上会影响性能)。


2
最底部的答案总是最相关的。 - CDR
是的,我认为许多人关心的开销并不是解引用的时间浪费,而是相对于一个常数地址值,它对于预测执行不够友好。但没有人会无缘无故地使用函数指针。当我们写了一个长的“switch-case”语句时,编译器通常会生成一个跳转表(一个函数指针数组),因为较慢的预测比错误的预测更好。 - weiweishuo
大多数现代CPU对于间接分支和条件分支都有良好的预测能力。然而,一些旧的/低功耗CPU对于间接分支的预测能力较弱。但是,如果调用点每次使用函数指针,它们通常仍然可以正常工作。 - Peter Cordes

10

很多人都已经提供了一些好的答案,但我仍然认为有一个点被忽略了。函数指针增加了额外的解引用操作,使它们变慢几个周期,这个数字可以根据不良分支预测而增加(顺便说一句,这几乎与函数指针本身无关)。此外,通过指针调用的函数不能被内联。但是人们所遗漏的是,大多数人使用函数指针作为优化手段。

在c/c++ API中最常见的使用函数指针的地方是作为回调函数。许多API之所以这样做,是因为编写一个在事件发生时调用函数指针的系统比其他方法(如消息传递)更有效率。就我个人而言,我曾将函数指针用作更复杂的输入处理系统的一部分,其中键盘上的每个按键都通过跳转表映射到一个函数指针。这使我能够从输入系统中删除任何分支或逻辑,并且只需处理按键按下事件即可。


1
嗨,您说过:“函数指针会增加额外的解引用操作,这会使它们变得慢几个周期,这个数量可能会因为分支预测不好而增加。” 所以,调用函数指针是否需要进行分支预测?但是您接着说,“就我个人而言,我也使用了函数指针……每个键盘按键都通过跳转表映射到一个函数指针。这使我能够消除任何分支……”,这意味着使用跳转表来调用函数指针可以避免分支预测失误。这两个陈述不是相互矛盾的吗?谢谢! - HCSF

8
通过函数指针调用函数比静态函数调用稍微慢一些,因为前者需要额外的指针解引用。但据我所知,在大多数现代计算机上(除了一些资源非常有限的特殊平台),这种差异可以忽略不计。
函数指针的使用可以使程序更简单、更清晰、更易于维护(当然,要正确使用)。这远远弥补了可能非常小的速度差异。

1
假设解引用需要一个CPU周期。在一台2GHz的机器上,这相当于500皮秒(或0.5纳秒)。即使它需要多个周期,仍然远远小于一毫秒。 - Peter K.
@Peter K. 谢谢 - 我真的不确定它是微秒还是纳秒范围 :-) - Péter Török
分支预测和推测执行意味着CPU在跟随“call reg”或“call [mem]”间接分支之前实际上不必等待从内存(或L1d缓存)加载。但是,如果目标地址无法尽早检查,则会增加分支错误预测的惩罚。 - Peter Cordes

8

之前的回复中有很多重要的观点。

然而,需要看一下 C qsort 比较函数。因为比较函数无法内联,需要遵循标准的基于栈的调用约定,所以对于整数键而言,排序的总运行时间可能会慢上数量级(更确切地说是 3-10 倍),与直接可内联调用的相同代码相比。

典型的内联比较将是一系列简单的 CMP 指令和可能的 CMOV/SET 指令序列。函数调用还会产生 CALL 的开销,设置栈帧,进行比较,拆卸栈帧并返回结果。请注意,由于 CPU 管道长度和虚拟寄存器,堆栈操作可能会导致管道停顿。例如,如果在执行最后一个修改 eax 的指令之前需要 eax 的值(这通常需要大约 12 个时钟周期在最新的处理器上),除非 CPU 可以按顺序执行其他指令等待它,否则管线停顿将发生。


是的,阻塞内联是不好的,但这里的其余部分是错误的。所有现代x86 CPU都使用带有寄存器重命名的乱序执行,完全避免了所有WAW和WAR危害。对eax的独立写入将启动一个新的依赖链。请参见http://agner.org/optimize/和[为什么Haswell上的mulss只需要3个周期,与Agner的指令表不同?](//stackoverflow.com/q/45113527)。 - Peter Cordes

7

使用函数指针比直接调用函数慢,因为它是另一层间接引用(需要解除引用指针以获取函数的内存地址)。虽然它比程序可能执行的其他操作(读取文件、向控制台写入)要慢,但这个差异可以忽略不计。

如果需要使用函数指针,请使用它们,因为任何试图避免使用它们并实现相同功能的方法都会比使用函数指针更慢、难以维护。


1
+1,我同意,与其中任何其他代码相比,减速将是微不足道的。 - user257111

4
可能是的。
答案取决于函数指针用于什么以及替代方案是什么。如果函数指针用于实现程序逻辑中不可简单删除的选择,则将函数指针调用与直接函数调用进行比较是具有误导性的。我将继续展示这种比较,并在随后回到这个想法。
当函数指针抑制内联时,它们与直接函数调用相比具有最大的性能下降机会。由于内联是一种关键优化,我们可以设计极端情况,使函数指针比等效的直接函数调用任意慢:
void foo(int* x) {
    *x = 0;
}

void (*foo_ptr)(int*) = foo;

int call_foo(int *p, int size) {
    int r = 0;
    for (int i = 0; i != size; ++i)
        r += p[i];
    foo(&r);
    return r;
}

int call_foo_ptr(int *p, int size) {
    int r = 0;
    for (int i = 0; i != size; ++i)
        r += p[i];
    foo_ptr(&r);
    return r;
}

call_foo()的代码生成结果:

call_foo(int*, int):
  xor eax, eax
  ret

不错。 foo() 不仅已经被内联,而且这样做允许编译器消除整个前置循环!生成的代码只需通过将寄存器与自身进行异或运算来清零返回寄存器,然后返回。另一方面,在 call_foo_ptr() 中编译器将不得不为循环生成代码(使用gcc 7.3超过100行),其中大部分代码实际上什么也不做(只要 foo_ptr 仍然指向 foo())。 (在更典型的情况下,可以预期将小函数内联到热内部循环中可能会将执行时间缩短约一个数量级。)
因此,在最坏的情况下,函数指针调用比直接函数调用慢得多,但这是误导性的。事实证明,如果 foo_ptrconst,那么 call_foo()call_foo_ptr() 将生成相同的代码。但是,这将要求我们放弃由 foo_ptr 提供的间接机会。 foo_ptr 是否应该是 const?如果我们对 foo_ptr 提供的间接引用感兴趣,那么不是,但如果是这种情况,直接函数调用也不是一个有效的选择。
如果正在使用函数指针提供有用的间接引用,则可以将间接引用移动或在某些情况下交换函数指针以进行条件或甚至宏。但我们不能简单地将其删除。如果我们已经决定函数指针是一个好方法,但性能是一个关注点,那么我们通常希望将间接引用从调用堆栈中提取出来,以便在外部循环中支付间接引用的成本。例如,在常见情况下,一个函数需要一个回调并在循环中调用它,我们可以尝试将最内层循环移入回调中(并相应地更改每个回调调用的责任)。

这里的讨论很好,谢谢! - oclyke

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接