函数 vs 模板

感谢 C + + 11,我们收到了函数包装器的 std::function家族。不幸的是,我一直听到关于这些新增设备的坏消息。最受欢迎的是它们慢得可怕。我测试了一下,和模板相比,它们真的很烂。

#include <iostream>
#include <functional>
#include <string>
#include <chrono>


template <typename F>
float calc1(F f) { return -1.0f * f(3.3f) + 666.0f; }


float calc2(std::function<float(float)> f) { return -1.0f * f(3.3f) + 666.0f; }


int main() {
using namespace std::chrono;


const auto tp1 = system_clock::now();
for (int i = 0; i < 1e8; ++i) {
calc1([](float arg){ return arg * 0.5f; });
}
const auto tp2 = high_resolution_clock::now();


const auto d = duration_cast<milliseconds>(tp2 - tp1);
std::cout << d.count() << std::endl;
return 0;
}

111ms vs 1241ms 我假设这是因为模板可以很好地内联,而 function通过虚拟调用覆盖内部。

显然,模板在我看来有它们的问题:

  • 它们必须作为头文件提供,这不是你在以封闭代码形式发布库时不希望做的事情,
  • 除非引入类似 extern template的策略,否则它们可能会使编译时间大大延长,
  • 没有(至少对我来说)表示需求(概念,有人知道吗?)的干净的方法除了描述所期望的函数类型的注释之外。

因此,我是否可以假设 function可以用作传递函数的 事实上标准,并且在需要使用高性能模板的地方使用?


编辑:

我的编译器是 VisualStudio2012没有CTP。

64965 次浏览

不一样是不一样的。

它比较慢,因为它可以完成模板无法完成的任务。特别是,它允许您调用 任何函数,该函数可以用给定的参数类型调用,其返回类型可转换为给定的返回类型 来自同一个密码

void eval(const std::function<int(int)>& f) {
std::cout << f(3);
}


int f1(int i) {
return i;
}


float f2(double d) {
return d;
}


int main() {
std::function<int(int)> fun(f1);
eval(fun);
fun = f2;
eval(fun);
return 0;
}

请注意,一样函数对象 fun被传递给对 eval的两个调用。

如果你不需要这样做,那么你应该 没有使用 std::function

一般来说,如果你面临的是一个 设计的情况,给你一个选择,使用模板。我强调了 设计这个词,因为我认为您需要关注的是 std::function和模板的用例之间的区别,它们是非常不同的。

一般来说,模板的选择只是一个更广泛原则的实例: 尝试在编译时指定尽可能多的约束。其基本原理很简单: 如果在生成程序之前就能捕捉到错误或类型不匹配,那么就不会向客户发送有错误的程序。

此外,正如您正确指出的那样,对模板函数的调用是静态解析的(即在编译时) ,因此编译器拥有所有需要优化的必要信息,并且可能内联代码(如果通过 vtable 执行调用,这是不可能的)。

是的,模板支持确实不完美,C + + 11仍然缺乏对概念的支持; 但是,我不知道 std::function如何在这方面拯救您。std::function不是模板的替代品,而是用于设计不能使用模板的情况的工具。

当您需要通过调用一个遵循特定签名但其具体类型在编译时未知的可调用对象来解析一个调用 在运行时间时,就会出现这样一个用例。当您有一个可能是 不同的类型的回调集合,但是您需要 统一调用时,通常就是这种情况; 注册回调的类型和数量是在运行时根据程序的状态和应用程序逻辑确定的。其中一些回调函数可以是函数,一些可以是普通函数,一些可以是将其他函数绑定到某些参数的结果。

std::functionstd::bind还提供了一个用于在 C + + 中启用 函数式程序设计的自然习惯用法,其中函数被当作对象处理,并自然地被套用和组合以生成其他函数。虽然这种组合也可以通过模板来实现,但是类似的设计情况通常与需要在运行时确定组合可调用对象类型的用例一起出现。

最后,在其他情况下,std::function是不可避免的,例如,如果您想编写 递归的 lambdas; 然而,这些限制更多地是由技术限制而不是我认为的概念上的区别所决定的。

总之,专注于设计并尝试理解这两个构造的概念用例是什么。如果你把他们放在比较的方式,你是在强迫他们进入一个竞技场,他们可能不属于。

这里已经有了一些很好的答案,所以我不打算反驳它们,简而言之,比较标准: : function 和模板就像比较虚函数和函数一样。 您永远不应该“偏爱”虚函数而不是函数,而应该在满足问题时使用虚函数,将决策从编译时转移到运行时。我们的想法是,与其使用定制的解决方案(比如跳转表)来解决问题,不如使用一些东西,让编译器有更好的机会为您进行优化。如果您使用标准的解决方案,它还可以帮助其他程序员。

Andy Prowl 已经很好地涵盖了设计问题。当然,这是非常重要的,但是我相信最初的问题涉及到更多与 std::function相关的性能问题。

首先,简单介绍一下测量技术: calc1得到的11ms 没有任何意义。事实上,通过查看生成的程序集(或调试程序集代码) ,可以看出 VS2012的优化器足够聪明,能够意识到调用 calc1的结果与迭代无关,并将调用移出循环:

for (int i = 0; i < 1e8; ++i) {
}
calc1([](float arg){ return arg * 0.5f; });

此外,它意识到调用 calc1没有可见的效果,因此完全放弃了调用。因此,111ms 是空循环运行所需的时间。(我很惊讶优化器保持了循环。)所以,小心循环中的时间测量。这并不像看起来那么简单。

正如已经指出的那样,优化器在理解 std::function方面有更多的麻烦,并且不会将调用移出循环。所以1241ms 对于 calc2来说是一个公平的测量。

注意,std::function能够存储不同类型的可调用对象。因此,它必须为存储执行某种类型消除魔法。通常,这意味着动态内存分配(默认情况下通过调用 new)。众所周知,这是一个相当昂贵的操作。

标准(20.8.11.2.1/5)鼓励实现避免为小对象动态分配内存,谢天谢地,VS2012做到了这一点(特别是对于原始代码)。

为了了解在涉及到内存分配时它的速度会变慢多少,我修改了 lambda 表达式以捕获三个 float。这使得可调用对象太大,无法应用小对象优化:

float a, b, c; // never mind the values
// ...
calc2([a,b,c](float arg){ return arg * 0.5f; });

对于这个版本,时间大约为16000ms (相比之下,原始代码为1241ms)。

最后,请注意,lambda 的生命周期包含了 std::function的生命周期。在这种情况下,std::function不存储 lambda 的副本,而是存储对它的“引用”。我所说的“参考”是指 std::reference_wrapper,它很容易由函数 std::refstd::cref构建。更准确地说,通过使用:

auto func = [a,b,c](float arg){ return arg * 0.5f; };
calc2(std::cref(func));

时间减少到大约1860毫秒。

我之前写过这个:

Http://www.drdobbs.com/cpp/efficient-use-of-lambda-expressions-and/232500059

正如我在文章中所说,由于 VS2010对 C + + 11的支持不足,这些论点并不完全适用于 VS2010。在撰写本文时,只有 VS2012的 beta 版本可用,但是它对 C + + 11的支持已经足够好了。

对于 Clang,两者之间没有性能差异

使用 clang (3.2,主干166872)(- O2 on Linux) ,这两种情况下的二进制实际上是相同的

- 我会在文章结尾回来叮当。但首先,gcc 4.7。2:

虽然已经有了很多见解,但是我想指出的是,由于内联等原因,calc1和 calc2的计算结果并不相同。例如,比较所有结果的总和:

float result=0;
for (int i = 0; i < 1e8; ++i) {
result+=calc2([](float arg){ return arg * 0.5f; });
}

随着钙离子变成

1.71799e+10, time spent 0.14 sec

而对于 calc1它变成

6.6435e+10, time spent 5.772 sec

速度差是40倍,数值是4倍。第一个比 OP 发布的内容(使用可视化工作室)大得多。实际上,打印出一个结尾的值也是一个好主意,可以防止编译器删除没有可见结果的代码(如同规则一样)。凯西奥 · 内里在他的回答中已经说过了。注意结果有多么不同——在比较执行不同计算的代码的速度因子时应该小心。

此外,公平地说,比较各种重复计算 f (3.3)的方法也许并不那么有趣。如果输入是常数,那么它就不应该在循环中。(优化器很容易注意到)

如果我将用户提供的值参数添加到 calc1和 calc2,则 calc1和 calc2之间的速度因子从40降低到5!与视觉工作室的差异是接近一个因素的2,并与叮当有没有差异(见下文)。

此外,由于乘法很快,谈论减速因素往往不那么有趣。一个更有趣的问题是,你的函数有多小,这些调用是真正程序的瓶颈吗?

叮当声:

当我在 calc1和 calc2之间切换示例代码时,Clang (我使用3.2)实际上生成了 一模一样二进制文件(张贴在下面)。在问题中张贴的原始示例中,两者也是相同的,但是没有花费任何时间(如上所述,循环被完全移除)。用我修改过的例子,用 -O2:

执行的秒数(最多3秒) :

clang:        calc1:           1.4 seconds
clang:        calc2:           1.4 seconds (identical binary)


gcc 4.7.2:    calc1:           1.1 seconds
gcc 4.7.2:    calc2:           6.0 seconds


VS2012 CTPNov calc1:           0.8 seconds
VS2012 CTPNov calc2:           2.0 seconds


VS2015 (14.0.23.107) calc1:    1.1 seconds
VS2015 (14.0.23.107) calc2:    1.5 seconds


MinGW (4.7.2) calc1:           0.9 seconds
MinGW (4.7.2) calc2:          20.5 seconds

所有二进制文件的计算结果是相同的,所有测试都在同一台机器上执行。如果有更深入的叮当或 VS 知识的人可以评论什么优化可能已经完成,那将是很有趣的。

我修改过的测试代码:

#include <functional>
#include <chrono>
#include <iostream>


template <typename F>
float calc1(F f, float x) {
return 1.0f + 0.002*x+f(x*1.223) ;
}


float calc2(std::function<float(float)> f,float x) {
return 1.0f + 0.002*x+f(x*1.223) ;
}


int main() {
using namespace std::chrono;


const auto tp1 = high_resolution_clock::now();


float result=0;
for (int i = 0; i < 1e8; ++i) {
result=calc1([](float arg){
return arg * 0.5f;
},result);
}
const auto tp2 = high_resolution_clock::now();


const auto d = duration_cast<milliseconds>(tp2 - tp1);
std::cout << d.count() << std::endl;
std::cout << result<< std::endl;
return 0;
}

更新:

增加了 vs2015。我还注意到 calc1、 calc2中有双 > float 转换。移除它们并不会改变视觉工作室的结论(两者都快得多,但比例大致相同)。

这个答案旨在为现有的答案集提供一个我认为对于 std: : 函数调用的运行时成本来说更有意义的基准。

函数机制应该识别它所提供的内容: 任何可调用的实体都可以转换为具有适当签名的 std: : 函数。假设你有一个函数库,它可以将曲面与 z = f (x,y)定义的函数相匹配,你可以将它写成接受 std::function<double(double,double)>,这个库的用户可以很容易地将任何可调用的实体转换成这个函数; 它可以是一个普通函数,一个类实例的方法,或者一个 lambda,或者任何 std: : bind 支持的东西。

与模板方法不同,这种方法不需要为不同的情况重新编译库函数; 因此,对于每个额外的情况几乎不需要额外的编译代码。实现这一点一直是可能的,但是过去需要一些笨拙的机制,库的用户可能需要围绕其功能构造一个适配器来使其工作。函数自动构造任何需要的适配器,以获得所有情况下的通用 运行时间调用接口,这是一个非常强大的新特性。

在我看来,就性能而言,这是 std: : function 最重要的用例: 我对调用一个 std: : function 的代价很感兴趣,因为它已经构造了一次,而且需要在这种情况下,编译器无法通过知道实际调用的函数来优化调用(也就是说,你需要将实现隐藏在另一个源文件中以获得适当的基准测试)。

我做了下面的测试,类似于 OP; 但主要的变化是:

  1. 每个 case 循环10亿次,但是 std: : function 对象只构造一次。通过查看输出代码,我发现在构造实际的 std: : 函数调用时会调用‘ Operaternew’(当它们被优化掉时可能不会)。
  2. 将测试拆分为两个文件,以防止不必要的优化
  3. 我的例子是: (a)函数是内联的(b)函数是由普通的函数指针函数传递的(c)函数是包装为 std: : function (d)函数的兼容函数是与 std: : bind 兼容的不兼容函数,包装为 std: : function

我得到的结果是:

  • 个案(a)(行内)1.3 nsec

  • 所有其他情况: 3.3 nsec。

情况(d)往往稍慢,但差异(约0.05纳秒)是吸收在噪音。

结论是 std: : function 的开销(在调用时)相当于使用一个函数指针,即使只是简单地“绑定”适应实际的函数。内联比其他的快2 ns,但这是一个预期的折衷,因为内联是运行时唯一“硬连接”的情况。

当我在同一台机器上运行 johan-lundberg 的代码时,我看到每个循环大约39纳秒,但是在这个循环中还有更多的代码,包括 std: : 函数的实际构造函数和析构函数,这可能相当高,因为它涉及到一个 new 和 delete。

- O2 gcc 4.8.1,到 x86 _ 64目标(核心 i5)。

注意,代码被分解为两个文件,以防止编译器扩展调用它们的函数(除了一种情况)。

————第一个源文件——————

#include <functional>




// simple funct
float func_half( float x ) { return x * 0.5; }


// func we can bind
float mul_by( float x, float scale ) { return x * scale; }


//
// func to call another func a zillion times.
//
float test_stdfunc( std::function<float(float)> const & func, int nloops ) {
float x = 1.0;
float y = 0.0;
for(int i =0; i < nloops; i++ ){
y += x;
x = func(x);
}
return y;
}


// same thing with a function pointer
float test_funcptr( float (*func)(float), int nloops ) {
float x = 1.0;
float y = 0.0;
for(int i =0; i < nloops; i++ ){
y += x;
x = func(x);
}
return y;
}


// same thing with inline function
float test_inline(  int nloops ) {
float x = 1.0;
float y = 0.0;
for(int i =0; i < nloops; i++ ){
y += x;
x = func_half(x);
}
return y;
}

——第二个源文件——

#include <iostream>
#include <functional>
#include <chrono>


extern float func_half( float x );
extern float mul_by( float x, float scale );
extern float test_inline(  int nloops );
extern float test_stdfunc( std::function<float(float)> const & func, int nloops );
extern float test_funcptr( float (*func)(float), int nloops );


int main() {
using namespace std::chrono;




for(int icase = 0; icase < 4; icase ++ ){
const auto tp1 = system_clock::now();


float result;
switch( icase ){
case 0:
result = test_inline( 1e9);
break;
case 1:
result = test_funcptr( func_half, 1e9);
break;
case 2:
result = test_stdfunc( func_half, 1e9);
break;
case 3:
result = test_stdfunc( std::bind( mul_by, std::placeholders::_1, 0.5), 1e9);
break;
}
const auto tp2 = high_resolution_clock::now();


const auto d = duration_cast<milliseconds>(tp2 - tp1);
std::cout << d.count() << std::endl;
std::cout << result<< std::endl;
}
return 0;
}

对于那些感兴趣的人,下面是编译器为使“ mul _ by”看起来像一个 float (float)而构建的适配器——当作 bind (mul _ by,_ 1,0.5)创建的函数被调用时,这个适配器被“调用”:

movq    (%rdi), %rax                ; get the std::func data
movsd   8(%rax), %xmm1              ; get the bound value (0.5)
movq    (%rax), %rdx                ; get the function to call (mul_by)
cvtpd2ps    %xmm1, %xmm1        ; convert 0.5 to 0.5f
jmp *%rdx                       ; jump to the func

(所以如果我在绑定中写0.5 f 可能会快一点...) 注意,‘ x’参数到达% xmm0并停留在那里。

下面是在调用 test _ stdfunc-run through c + + filt 之前构造函数区域的代码:

movl    $16, %edi
movq    $0, 32(%rsp)
call    operator new(unsigned long)      ; get 16 bytes for std::function
movsd   .LC0(%rip), %xmm1                ; get 0.5
leaq    16(%rsp), %rdi                   ; (1st parm to test_stdfunc)
movq    mul_by(float, float), (%rax)     ; store &mul_by  in std::function
movl    $1000000000, %esi                ; (2nd parm to test_stdfunc)
movsd   %xmm1, 8(%rax)                   ; store 0.5 in std::function
movq    %rax, 16(%rsp)                   ; save ptr to allocated mem


;; the next two ops store pointers to generated code related to the std::function.
;; the first one points to the adaptor I showed above.


movq    std::_Function_handler<float (float), std::_Bind<float (*(std::_Placeholder<1>, double))(float, float)> >::_M_invoke(std::_Any_data const&, float), 40(%rsp)
movq    std::_Function_base::_Base_manager<std::_Bind<float (*(std::_Placeholder<1>, double))(float, float)> >::_M_manager(std::_Any_data&, std::_Any_data const&, std::_Manager_operation), 32(%rsp)




call    test_stdfunc(std::function<float (float)> const&, int)

我发现你的结果非常有趣,所以我做了一些挖掘,以了解是怎么回事。首先,正如许多其他人所说的,如果没有计算结果的影响,编译器只会优化程序的状态。其次,如果把常数3.3作为回调的武器,我怀疑还会有其他的优化。考虑到这一点,我对您的基准测试代码做了一点改动。

template <typename F>
float calc1(F f, float i) { return -1.0f * f(i) + 666.0f; }
float calc2(std::function<float(float)> f, float i) { return -1.0f * f(i) + 666.0f; }
int main() {
const auto tp1 = system_clock::now();
for (int i = 0; i < 1e8; ++i) {
t += calc2([&](float arg){ return arg * 0.5f + t; }, i);
}
const auto tp2 = high_resolution_clock::now();
}

考虑到对代码的这种更改,我使用 gcc 4.8-O3编译代码,得到 calc1的时间为330ms,calc2的时间为2702。所以使用模板的速度要快8倍,这个数字对我来说很可疑,速度的8次方往往表明编译器已经向量化了一些东西。当我查看为模板版本生成的代码时,它显然是向量化的

.L34:
cvtsi2ss        %edx, %xmm0
addl    $1, %edx
movaps  %xmm3, %xmm5
mulss   %xmm4, %xmm0
addss   %xmm1, %xmm0
subss   %xmm0, %xmm5
movaps  %xmm5, %xmm0
addss   %xmm1, %xmm0
cvtsi2sd        %edx, %xmm1
ucomisd %xmm1, %xmm2
ja      .L37
movss   %xmm0, 16(%rsp)

而函数版本不是。这对我来说是有意义的,因为使用模板编译器可以确定函数在整个循环过程中永远不会改变,但是传入的 std: : 函数可能会改变,因此不能向量化。

这促使我尝试其他方法,看看是否可以让编译器在 std: : 函数版本上执行相同的优化。我没有传入函数,而是创建了一个 std: : function 作为全局变量,并将其调用为。

float calc3(float i) {  return -1.0f * f2(i) + 666.0f; }
std::function<float(float)> f2 = [](float arg){ return arg * 0.5f; };


int main() {
const auto tp1 = system_clock::now();
for (int i = 0; i < 1e8; ++i) {
t += calc3([&](float arg){ return arg * 0.5f + t; }, i);
}
const auto tp2 = high_resolution_clock::now();
}

在这个版本中,我们可以看到编译器现在已经用相同的方式向量化了代码,并且我得到了相同的基准测试结果。

  • 模板: 330毫秒
  • 功能: 2702ms
  • Global std: : function: 330ms 全球标准: : 功能: 330ms

因此,我的结论是,std: : function 与模板函数的原始速度几乎是相同的。然而,这使得优化器的工作更加困难。

如果你在 C + + 20中使用 模板而不是 std::function,你实际上可以用可变模板(受 Hendrik Niemeyer 关于 C + + 20概念的演讲的启发)来编写你自己的 概念:

template<class Func, typename Ret, typename... Args>
concept functor = std::regular_invocable<Func, Args...> &&
std::same_as<std::invoke_result_t<Func, Args...>, Ret>;

然后可以将其用作 functor<Ret, Args...> F>,其中 Ret是返回值,Args...是可变输入参数。例如 functor<double,int> F

template <functor<double,int> F>
auto CalculateSomething(F&& f, int const arg) {
return f(arg)*f(arg);
}

需要一个函数作为模板参数,它必须重载 ()运算符,并且具有 double返回值和 int类型的单个输入参数。类似地,functor<double>是一个返回类型为 double的函数,它不接受任何输入参数。

试试这里!

您还可以将它与 可变函数一起使用,例如

template <typename... Args, functor<double, Args...> F>
auto CalculateSomething(F&& f, Args... args) {
return f(args...)*f(args...);
}

试试这里!