函数指针性能;单次通话比多次通话慢



我对通过指针调用的函数的执行速度感兴趣。我最初发现,通过作为参数传入的指针调用函数指针比调用本地声明的函数指针慢。请参阅以下代码;您可以看到,我有两个函数调用,它们最终都通过函数指针执行lambda。

#include <chrono>
#include <iostream>
using namespace std;
__attribute__((noinline)) int plus_one(int x) {
return x + 1;
}
typedef int (*FUNC)(int);
#define OUTPUT_TIME(msg) std::cout << "Execution time (ns) of " << msg << ": " << std::chrono::duration_cast<chrono::nanoseconds>(t_end - t_start).count() << std::endl;
#define START_TIMING() auto const t_start = std::chrono::high_resolution_clock::now();
#define END_TIMING(msg) auto const t_end = std::chrono::high_resolution_clock::now(); OUTPUT_TIME(msg);
auto constexpr g_count = 1000000;

__attribute__((noinline)) int speed_test_no_param() {
int r;
auto local_lambda = [](int a) {
return plus_one(a);
};
FUNC f = local_lambda;
START_TIMING();
for (auto i = 0; i < g_count; ++i)
r = f(100);
END_TIMING("speed_test_no_param");

return r;
}
__attribute__((noinline)) int speed_test_with_param(FUNC &f) {
int r;
START_TIMING();
for (auto i = 0; i < g_count; ++i)
r = f(100);
END_TIMING("speed_test_with_param");

return r;
}
int main() {
int ret = 0;
auto main_lambda = [](int a) {
return plus_one(a);
};
ret += speed_test_no_param();
FUNC fp = main_lambda;
ret += speed_test_with_param(fp);
return ret;
}

建立在Ubuntu 20.04与:

g++ -ggdb -ffunction-sections -O3 -std=c++17   -DNDEBUG=1 -DRELEASE=1  -c speed_test.cpp -o speed_test.o && g++ -o speed_test -Wl,-gc-sections    -Wl,--start-group speed_test.o   -Wl,--rpath='$ORIGIN'   -Wl,--end-group

结果并不令人惊讶;对于任何给定数量的运行,我们可以看到没有参数的版本显然是最快的。这只是一次跑步;我运行了很多次,结果都是一样的:

Execution time (ns) of speed_test_no_param: 74
Execution time (ns) of speed_test_with_param: 1173849

当我深入研究大会时,我发现了我认为的原因。speed_test_no_param()的代码为:

0x000055555555534b  call 0x555555555310 <plus_one(int)> 

而CCD_ 2的代码更为复杂;获取lambda的地址,然后跳转到plus_one函数:

0x000055555555544e  call QWORD PTR [rbx] 
...
0x0000555555555324  jmp 0x555555555310 <plus_one(int)> 

(在编译器资源管理器上,位于https://godbolt.org/z/b4hqYx7Eo.不同的编译器但相似的汇编;计时代码注释掉了。)

然而,我没有想到的是,当我将调用次数从1000000(auto constexpr g_count = 1)减少到1时,结果会翻转,参数版本最快:

Execution time (ns) of speed_test_no_param: 61
Execution time (ns) of speed_test_with_param: 31

我也跑过很多次;参数版本总是最快的。

我不明白为什么会这样;由于这种相互矛盾的证据,我现在不认为通过参数调用比本地变量慢,但查看程序集表明它确实应该慢

有人能解释一下吗?

更新

根据下面的评论,订购很重要。当我第一次调用speed_test_with_param()时,speed_test_no_param()是两个中最快的!然而,当我第一次呼叫speed_test_no_param()时,speed_test_with_param()是最快的!如有任何解释,我们将不胜感激!

在C++源中有多个循环迭代,快速版本只在asm中进行一次,因为您给了优化器足够的可见性来证明这是等效的。

为什么排序只需要一次迭代就很重要:可能是std::chrono库代码中的预热效果。绩效评估的惯用方法?

你能证实我的怀疑吗?从技术上讲,没有参数的调用应该是最快的,因为有参数需要读取内存来找到调用的位置?

更重要的是编译器是否可以恒定地传播函数指针,并查看调用了什么函数;注意speed_test_with_param有一个实际的循环,它调用g_count次,但speed_test_no_param可以看到它在调用plus_one。Clang查看了本地lambda和noinline,注意到它没有副作用,所以它只调用一次。

它没有内联,但仍然进行过程间优化。使用GCC,您可以使用__attribute__((noipa))来阻止它。GCC的noclone属性也可以阻止它复制不断传播的函数,但我认为noipa更强noinline不足以进行基准测试,因为当编译器可以看到所有内容时,这些内容变得微不足道,无法进行优化但我不认为叮当有那样的事。

您可以使用-fltospeed_test_with_param0 等其他选项,将函数放在单独的源文件中,使其对优化器不透明,而不是


函数指针涉及存储/重新加载的唯一原因是,您通过引用无故传递了它,即使它只是一个指针。如果按值传递(https://godbolt.org/z/WEvvsvoxb)您可以在循环中看到CCD_ 21。

显然,clang无法提升负载,因为它不确定调用方的函数指针不会被调用修改,因为它正在制作一个独立版本的speed_test_with_param,可以与任何调用方和任何arg一起使用,而不仅仅是main传递的那个。所以constprop没有发生。

间接调用可能更容易预测错误,在检查预测之前,yes存储/重新加载会增加几个周期的延迟。

所以,是的,通常情况下,当要调用的函数是函数指针arg,而不是在调用函数中初始化的编译时常数fptr时,编译器可以看到它调用的内容的定义,即使你人为地限制它。

如果它变成call some_name而不是call rbx,那么即使它仍然必须像你试图制作它一样循环,也会更快

(微基准测试很难,尤其是当你试图对一个C++概念进行基准测试时,它可以根据上下文进行不同的优化;你必须对编译器、优化和汇编有足够的了解,才能意识到是什么造成了差异,以及你实际测量的是什么。对于一些问题,比如"+运算符有多快或有多慢?"您可以将其限制为整数,因为它可以使用常量进行优化,或者矢量化,或者不使用,这取决于它的使用方式。)

您正在对单个迭代进行基准测试,这会使您受到缓存效果和其他预热成本的影响。我们通常多次运行基准测试的全部原因是为了摊销这些影响。

缓存指的是内存层次结构:你的实际RAM比你的CPU慢得多(磁盘更慢),所以为了加快速度,你的CPU有一个缓存(通常是多个缓存),用来存储最近访问的内存。第一次启动程序时,需要将其从磁盘加载到RAM中;此后,它将需要从RAM加载到CPU高速缓存中。未缓存内存访问可能比缓存内存访问慢几个数量级。当程序运行时,各种代码和数据将从RAM加载并缓存;因此,相同代码位的后续执行通常会比第一次执行更快。

其他影响可能包括延迟动态链接和延迟初始化,其中某些函数在第一次被调用时将执行额外的工作(例如,解析动态库加载或初始化静态数据)。这些都可能导致第一次迭代比后续迭代慢。

要解决这些问题,请始终确保多次运行基准测试,如果可能,请在一个过程中运行整个基准测试套件几次,并以最低(最快)的速度运行。

最新更新