C++ std::function性能开销分析 | 函数对象性能测试

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的lambda5个周期
调用std::function的函数或函数指针7个周期
构造空的std::function7个周期
从函数或函数指针构造std::function21个周期
复制std::function21..24个周期
从无捕获的lambda构造std::function7个周期
从有捕获的lambda构造std::function21+个周期

一个警告:这些基准测试真正只代表相对于a + b的开销。不同的函数显示出略有不同的开销行为,因为它们可能会使用不同的调度端口和执行单元,这些单元与循环所需的不同重叠。此外,很多这取决于编译器的内联意愿。

我们只测量了吞吐量。这些结果仅适用于"多次调用同一个函数,传入不同参数",而不适用于"调用许多不同的函数"。但这是另一个主题的内容。

注:本文是之前看到的一篇关于std::function性能分析的英文文章的翻译,但原文链接已无法找到。如有侵权,请联系我删除。