std :: function vs template

Red*_*III 156 c++ templates c++11 std-function

感谢C++ 11,我们收到了std::functionfunctor包装器系列.不幸的是,我一直只听到关于这些新增内容的不好的事情.最受欢迎的是它们非常慢.我对它进行了测试,与模板相比,它们真的很糟糕.

#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;
}
Run Code Online (Sandbox Code Playgroud)

111毫秒vs 1241毫秒.我认为这是因为模板可以很好地内联,而functions通过虚拟调用覆盖内部.

显然模板有我们看到它们的问题:

  • 它们必须作为标题提供,当您将库作为封闭代码发布时,这不是您可能不希望做的事情,
  • 它们可能会使编译时间更长,除非extern template引入政策,
  • 没有(至少我知道)表达模板的要求(概念,任何人?)的清洁方式,禁止描述期望什么样的仿函数的注释.

我可以因此假设functions可以用作传递函子的事实标准,并且在需要高性能的地方应该使用模板吗?


编辑:

我的编译器是没有 CTP 的Visual Studio 2012 .

And*_*owl 167

通常,如果您面临可以选择的设计情境,请使用模板.我强调了设计这个词,因为我认为你需要关注的是用例std::function和模板之间的区别,这是非常不同的.

通常,模板的选择只是更广泛原则的一个实例:尝试在编译时指定尽可能多的约束.基本原理很简单:如果您可以在生成程序之前发现错误或类型不匹配,则不会向您的客户发送错误程序.

此外,正如您正确指出的那样,对模板函数的调用是静态解析的(即在编译时),因此编译器具有所有必要的信息来优化并可能内联代码(如果调用是通过虚函数表).

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

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

std::function并且std::bind还提供了一种自然的习惯用语,用于在C++中实现函数式编程,其中函数被视为对象,并且自然地被咖喱和组合以生成其他函数.虽然这种组合也可以通过模板实现,但是类似的设计情况通常与需要在运行时确定组合的可调用对象的类型的用例一起出现.

最后,还有其他情况std::function是不可避免的,例如,如果你想写递归lambda ; 然而,这些限制更多地受技术限制的支配,而不是我认为的概念差异.

总而言之,专注于设计并尝试理解这两种结构的概念用例.如果按照你的方式对它们进行比较,你就会迫使它们进入他们可能不属于的竞技场.

  • 我认为"当你有一组可能不同类型的回调时,通常就是这种情况,但你需要统一调用它们;" 是重要的一点.我的经验法则是:"在存储端更喜欢`std :: function`,在接口上使用模板`Fun`". (22认同)
  • 注意:隐藏具体类型的技术称为*类型擦除*(不要与托管语言中的类型擦除混淆).它通常是根据动态多态实现的,但功能更强大(例如`unique_ptr <void>`甚至对没有虚拟析构函数的类型调用适当的析构函数). (2认同)
  • @ecatmur:我同意这个内容,虽然我们在术语上略微不对齐.动态多态意味着我"在运行时假设不同的形式",而不是静态多态,我将其解释为"在编译时假设不同的形式"; 后者无法通过模板实现.对我来说,类型擦除在设计方面是一种能够实现动态多态性的前提条件:你需要一些统一的接口来与不同类型的对象进行交互,而类型擦除是一种抽象类型的方法 - 具体信息. (2认同)
  • @ecatmur:所以在某种程度上,动态多态是概念模式,而类型擦除是一种允许实现它的技术. (2认同)
  • @Downvoter:我很想知道你在这个答案中发现了什么错误. (2认同)

Cas*_*eri 87

Andy Prowl很好地涵盖了设计问题.当然,这非常重要,但我认为最初的问题涉及与此相关的更多性能问题std::function.

首先,快速评论测量技术:获得的11ms calc1完全没有意义.实际上,查看生成的程序集(或调试汇编代码),可以看出VS2012的优化器足够聪明,可以意识到调用的结果calc1与迭代无关并将调用移出循环:

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

此外,它意识到呼叫calc1没有明显的效果并完全放弃呼叫.因此,111ms是空循环运行的时间.(我对优化器保持循环感到惊讶.)因此,请注意循环中的时间测量.这并不像看起来那么简单.

正如已经指出的那样,优化器有更多的麻烦需要理解std::function,并且不会将调用移出循环.所以1241ms是一个公平的衡量标准calc2.

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

标准(20.8.11.2.1/5)包含实现以避免小对象的动态内存分配,幸好VS2012(特别是原始代码).

为了了解在涉及内存分配时可以获得多少速度,我已经将lambda表达式更改为捕获三个floats.这使得可调用对象太大而无法应用小对象优化:

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

对于此版本,时间约为16000毫秒(相比原始代码为1241毫秒).

最后,请注意lambda的生命周期包含了lambda的生命周期std::function.在这种情况下,不是存储lambda的副本,而是std::function可以存储"引用".通过"参考"我的意思是std::reference_wrapper这是很容易通过功能建设std::refstd::cref.更确切地说,通过使用:

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

时间减少到大约1860毫秒.

我刚才写过:

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

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


Joh*_*erg 37

有了Clang,两者之间没有任何性能差异

使用clang(3.2,trunk 166872)(在Linux上为-O2),两个案例中的二进制文件实际上是相同的.

- 我会在帖子的最后回来铿锵作响.但首先,gcc 4.7.2:

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

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

与calc2成为

1.71799e+10, time spent 0.14 sec
Run Code Online (Sandbox Code Playgroud)

而使用calc1它变成了

6.6435e+10, time spent 5.772 sec
Run Code Online (Sandbox Code Playgroud)

这是速度差的〜40倍,值是因子〜4.第一个是比OP发布的更大的差异(使用visual studio).实际上打印出值,结束也是一个好主意,以防止编译器删除没有可见结果的代码(如果规则).Cassio Neri在他的回答中已经说过了这一点.注意结果有多么不同 - 在比较执行不同计算的代码的速度因子时应该小心.

另外,公平地说,比较反复计算f(3.3)的各种方法可能并不那么有趣.如果输入是常量,则它不应该处于循环中.(优化器很容易注意到)

如果我将用户提供的值参数添加到calc1和2,则calc1和calc2之间的速度因子从40减少到5!使用visual studio,差异接近2倍,而clang则没有区别(见下文).

此外,由于乘法速度很快,因此谈论减速因素通常并不那么有趣.一个更有趣的问题是,你的功能有多小,这些调用是真正程序中的瓶颈吗?

铛:

当我在示例代码(下面发布)的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 
Run Code Online (Sandbox Code Playgroud)

所有二进制文件的计算结果都是相同的,所有测试都在同一台机器上执行.如果有更深层次的铿锵声或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;
}
Run Code Online (Sandbox Code Playgroud)

更新:

添加vs2015.我还注意到calc1,calc2中有double-> float转换.删除它们不会改变visual studio的结论(两者都快得多,但比例大致相同).

  • 可以说只是表明基准是错误的.恕我直言,有趣的用例是调用代码从其他地方接收函数对象,因此编译器在编译调用时不知道std :: function的来源.这里,编译器通过将calc2内联扩展为main来确切地知道std :: function的组合.通过在sep中制作calc2'extern'轻松修复.源文件.然后你比较苹果与橙子; calc2正在做一些calc1不能做的事情.并且,循环可以在calc内(对f的许多调用); 不在函数对象的ctor周围. (8认同)
  • BTW基准测试没有错,问题("std :: function vs template")仅在同一编译单元的范围内有效.如果将该功能移动到另一个单元,则不再可以使用模板,因此无需进行比较. (3认同)

Pet*_*ker 13

不同是不一样的.

它更慢,因为它做了模板无法做的事情.特别是,它允许您调用任何可以使用给定参数类型调用的函数,并且其返回类型可以从同一代码转换为给定的返回类型.

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;
}
Run Code Online (Sandbox Code Playgroud)

请注意,同一个函数对象fun将被传递给两个调用eval.它有两个不同的功能.

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

  • 只想指出当'fun = f2'完成时,'fun'对象最终指向一个隐藏函数,它将int转换为double,调用f2,并将double结果转换回int.(在实际示例中) ,'f2'可以内联到该函数中).如果将std :: bind分配给fun,则"fun"对象最终可能包含要用于绑定参数的值.为了支持这种灵活性,分配给'fun'(或init)可能涉及分配/释放内存,并且它可能需要比实际的调用开销更长的时间. (2认同)

小智 8

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


gre*_*ggo 6

这个答案旨在为现有答案集做出贡献,我认为这是std :: function调用的运行时成本的更有意义的基准.

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

与模板方法不同,这不需要为不同的情况重新编译库函数; 因此,每个附加案例都需要很少的额外编译代码.它总是可以实现这一点,但它曾经需要一些笨拙的机制,并且库的用户可能需要围绕它们的函数构建适配器以使其工作.std :: function自动构造所需的任何适配器,以获得所有情况的公共运行时调用接口,这是一个新的非常强大的功能.

在我看来,就性能而言,这是std :: function最重要的用例:我感兴趣的是在构造一次后多次调用std :: function的成本,并且它需要通过知道实际调用的函数(即,您需要在另一个源文件中隐藏实现以获得适当的基准),编译器无法优化调用的情况.

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

  1. 每个case循环10亿次,但std :: function对象只构造一次.通过查看输出代码,我发现在构造实际的std :: function调用时调用了'operator new'(当它们被优化时可能不会).
  2. 测试分为两个文件以防止意外的优化
  3. 我的情况是:(a)函数内联(b)函数是由普通函数指针传递的(c)函数是兼容函数包装为std :: function(d)函数是一个与std兼容的不兼容函数::绑定,包装为std :: function

我得到的结果是:

  • 情况(a)(内联)1.3纳秒

  • 所有其他情况:3.3纳秒.

情况(d)倾向于略微缓慢,但差异(约0.05nsec)被噪声吸收.

结论是std :: function与使用函数指针的开销(在调用时)相当,即使在对实际函数进行简单的"绑定"调整时也是如此.内联比其他内容快2 ns,但这是预期的权衡,因为内联是唯一在运行时"硬连线"的情况.

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

-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;
}
Run Code Online (Sandbox Code Playgroud)

-----第二个源文件-------------

#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;
}
Run Code Online (Sandbox Code Playgroud)

对于那些感兴趣的人,这里是编译器构建的适配器使'mul_by'看起来像一个浮点数(浮点数) - 当调用创建为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
Run Code Online (Sandbox Code Playgroud)

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

这是构造函数的区域中的代码,在调用test_stdfunc之前 - 运行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)
Run Code Online (Sandbox Code Playgroud)


2b-*_*b-t 6

如果您使用模板而不是C++20std::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>;
Run Code Online (Sandbox Code Playgroud)

然后您可以将其用作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);
}
Run Code Online (Sandbox Code Playgroud)

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

在这里尝试一下!

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

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

在这里尝试一下!