C++ std::function:性能开销深度分析 #
引言 #
std::function是C++中常用的函数封装工具,但其性能开销常被讨论。本文深入分析它的实际性能表现。
经常看到有人说std::function
开销较大,建议慎用std::function
之类的讨论。
那std::function
究竟哪里开销大呢?我找到了一篇为std::function
做性能测试的文章,这篇文章中的英文比较简单,我就不翻译了,英文吃力的朋友也可以直接看下面的数据:
流行的说法是,如果你在意性能,就不要使用std::function
。
但这真的成立吗?它到底有多糟糕?
对std::function
进行纳秒级基准测试
基准测试很难。微观基准测试是一门黑暗的艺术。许多人坚持认为,纳秒级基准测试超出了我们凡人的能力范围。
但这不会阻止我们:让我们来测试一下创建和调用std::function
的开销。
我们必须在这里格外小心。现代台式机CPU非常复杂,通常具有深层次的流水线、乱序执行、复杂的分支预测、预取功能、多级缓存、超线程以及许多其他神秘的性能增强功能。
另一个敌人是编译器。
任何足够先进的优化编译器都与魔法难以区分。
我们必须确保我们要测试的代码不会被优化掉。幸运的是,volatile
尚未完全废弃,可以用来防止许多优化。在这个测试中,我们只测量吞吐量(调用同一个函数1000000次需要多长时间?)。我们将使用以下框架:
template<class F>
void benchmark(F&& f, float a_in = 0.0f, float b_in = 0.0f){
auto constexpr count = 1'000'000;
volatile float a = a_in;
volatile float b = b_in;
volatile float r;
auto const t_start = std::chrono::high_resolution_clock::now();
for (auto i = 0; i < count; ++i)
r = f(a, b);
auto const t_end = std::chrono::high_resolution_clock::now();
auto const dt = std::chrono::duration<double>(t_end - t_start).count();
std::cout << dt / count * 1e9 << " ns / op" << std::endl;
}
通过godbolt再次检查,我们可以验证编译器是否优化了函数体,即使我们在循环中只计算0.0f + 0.0f
。循环本身有一些开销,有时编译器会部分展开循环。
基准线 #
我们在以下基准测试中的测试系统是英特尔酷睿i9-9900K,运行频率为4.8 GHz(在写作时是一款现代高端消费级CPU)。代码是使用clang-7和libcstd++标准库编译的,编译选项为-O2和-march=native。
我们从几个基本测试开始:
benchmark([](float, float) { return 0.0f; }); // 0.21 ns / op (每操作1个周期)
benchmark([](float a, float b) { return a + b; }); // 0.22 ns / op (每操作1个周期)
benchmark([](float a, float b) { return a / b; }); // 0.62 ns / op (每操作3个周期)
基准线大约是每操作1个周期,a / b
测试验证了我们可以重现基本操作的吞吐量(一个很好的参考是AsmGrid,在右上角的X86性能)。我已经多次重复了所有基准测试,并选择了分布的众数。
调用函数 #
我们首先想知道:函数调用的开销有多大?
using fun_t = float(float, float);
// 可内联的直接调用
float funA(float a, float b) { return a + b; }
// 不内联的直接调用
__attribute__((noinline)) float funB(float a, float b) { return a + b; }
// 不内联的间接调用
fun_t* funC; // 外部设置为funA
// 可见的lambda
auto funD = [](float a, float b) { return a + b; };
// 包含可见函数的std::function
auto funE = std::function<fun_t>(funA);
// 包含不内联函数的std::function
auto funF = std::function<fun_t>(funB);
// 包含函数指针的std::function
auto funG = std::function<fun_t>(funC);
// 包含可见lambda的std::function
auto funH = std::function<fun_t>(funD);
// 包含直接lambda的std::function
auto funI = std::function<fun_t>([](float a, float b) { return a + b; });
测试结果如下:
benchmark(funA); // 0.22 ns / op (每操作1个周期)
benchmark(funB); // 1.04 ns / op (每操作5个周期)
benchmark(funC); // 1.04 ns / op (每操作5个周期)
benchmark(funD); // 0.22 ns / op (每操作1个周期)
benchmark(funE); // 1.67 ns / op (每操作8个周期)
benchmark(funF); // 1.67 ns / op (每操作8个周期)
benchmark(funG); // 1.67 ns / op (每操作8个周期)
benchmark(funH); // 1.25 ns / op (每操作6个周期)
benchmark(funI); // 1.25 ns / op (每操作6个周期)
这表明只有A和D被内联,并且在使用std::function
时,使用lambda时可能有一些额外的优化。
构造std::function
#
我们还可以测量构造或复制std::function
需要多长时间:
std::function<float(float, float)> f;
benchmark([&]{ f = {}; }); // 0.42 ns / op (每操作2个周期)
benchmark([&]{ f = funA; }); // 4.37 ns / op (每操作21个周期)
benchmark([&]{ f = funB; }); // 4.37 ns / op (每操作21个周期)
benchmark([&]{ f = funC; }); // 4.37 ns / op (每操作21个周期)
benchmark([&]{ f = funD; }); // 1.46 ns / op (每操作7个周期)
benchmark([&]{ f = funE; }); // 5.00 ns / op (每操作24个周期)
benchmark([&]{ f = funF; }); // 5.00 ns / op (每操作24个周期)
benchmark([&]{ f = funG; }); // 5.00 ns / op (每操作24个周期)
benchmark([&]{ f = funH; }); // 4.37 ns / op (每操作21个周期)
benchmark([&]{ f = funI; }); // 4.37 ns / op (每操作21个周期)
f = funD
的结果表明,直接从lambda构造std::function
相当快。让我们检查一下使用不同捕获大小时的情况:
struct b4 { int32_t x; };
struct b8 { int64_t x; };
struct b16 { int64_t x, y; };
benchmark([&]{ f = [](float, float) { return 0; }; }); // 1.46 ns / op (每操作7个周期)
benchmark([&]{ f = [x = b4{}](float, float) { return 0; }; }); // 4.37 ns / op (每操作21个周期)
benchmark([&]{ f = [x = b8{}](float, float) { return 0; }; }); // 4.37 ns / op (每操作21个周期)
benchmark([&]{ f = [x = b16{}](float, float) { return 0; }; }); // 1.66 ns / op (每操作8个周期)
我没有耐心去梳理汇编代码或libcstd++实现来检查这种行为的来源。你必须为捕获付出代价,我认为我们在这里看到的是某种小函数优化与编译器将b16{}的构造提升到测量循环之外的奇怪交互。
总结 #
我认为关于std::function
有很多恐吓言论,其中并非全部都是合理的。
我的基准测试表明,在现代微架构上,在热数据和指令缓存的情况下,可以预期以下开销:
操作 | 周期 |
---|---|
调用非内联函数 | 4个周期 |
调用函数指针 | 4个周期 |
调用std::function 的lambda | 5个周期 |
调用std::function 的函数或函数指针 | 7个周期 |
构造空的std::function | 7个周期 |
从函数或函数指针构造std::function | 21个周期 |
复制std::function | 21..24个周期 |
从无捕获的lambda构造std::function | 7个周期 |
从有捕获的lambda构造std::function | 21+个周期 |
一个警告:这些基准测试真正只代表相对于a + b
的开销。不同的函数显示出略有不同的开销行为,因为它们可能会使用不同的调度端口和执行单元,这些单元与循环所需的不同重叠。此外,很多这取决于编译器的内联意愿。
我们只测量了吞吐量。这些结果仅适用于"多次调用同一个函数,传入不同参数",而不适用于"调用许多不同的函数"。但这是另一个主题的内容。
注:本文是之前看到的一篇关于std::function性能分析的英文文章的翻译,但原文链接已无法找到。如有侵权,请联系我删除。