std :: function vs template

时间:2013-02-03 22:21:08

标签: c++ templates c++11 std-function

感谢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;
}

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

显然模板在我看来时会遇到问题:

  • 它们必须作为标题提供,而这些标题在将您的库作为封闭代码发布时可能不希望这样做,
  • 除非引入extern template - 类似政策,否则他们可能会使编译时间更长,
  • 没有(至少我知道)代表模板的要求(概念,任何人?)的干净方式,禁止描述期望什么样的仿函数的评论。

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


编辑:

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

8 个答案:

答案 0 :(得分:162)

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

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

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

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

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

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

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

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

答案 1 :(得分:85)

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 s。这使得可调用对象太大而无法应用小对象优化:

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

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

最后,请注意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

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

答案 2 :(得分:36)

使用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; });
}

使用calc2成为

1.71799e+10, time spent 0.14 sec

与calc1一起变成

6.6435e+10, time spent 5.772 sec

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

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

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

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

锵:

Clang(我使用3.2)实际上生成了相同的二进制文件,当我在示例代码(下面发布)的calc1和calc2之间切换时。使用在问题中发布的原始示例两者也是相同的但是根本不花时间(如上所述,循环被完全移除)。使用我的修改示例,使用-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中有双重&gt;浮点转换。删除它们不会改变visual studio的结论(两者都快得多,但比例大致相同)。

答案 3 :(得分: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;
}

请注意,相同的函数对象fun正在传递给eval的两个调用。它拥有两个不同的函数。

如果您不需要这样做,那么您应使用std::function

答案 4 :(得分:8)

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

答案 5 :(得分:6)

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

应该识别std :: function机制提供的内容:任何可调用的实体都可以转换为适当签名的std :: function。假设您有一个适合z = f(x,y)定义的函数的表面的库,您可以将其编写为接受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 :: bind,包装为std :: function
  4. 我得到的结果是:

    • 案例(a)(内联)1.3纳秒

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

    情况(d)往往略微变慢,但差异(约0.05纳秒)被噪音吸收。

    结论是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;
    }
    

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

    #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'看起来像一个浮点数(浮点数) - 当调用创建为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.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)
    

答案 6 :(得分:4)

我发现你的结果非常有趣,所以我做了一些挖掘以了解发生了什么。首先,正如许多其他人所说的那样,计算结果不会影响程序的状态,编译器只会优化它。其次有一个常量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 :: function版本不在哪里。这对我来说很有意义,因为使用模板,编译器肯定知道函数在整个循环中永远不会改变,但是传入的std :: function可能会改变,因此无法进行矢量化。

这导致我尝试其他东西,看看我是否可以让编译器在std :: function版本上执行相同的优化。我没有传入函数,而是将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毫秒
  • std :: function:2702ms
  • global std :: function:330ms

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

答案 7 :(得分:1)

如果您在 C++20 中使用 模板 而不是 std::function,您实际上可以编写自己的概念它的可变参数模板 (inspired by Hendrik Niemeyer's talk about C++20 concepts):

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 返回类型的函子,它不接受任何输入参数。

Try it here!

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

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

Try it here!