模板+函子/ lambda在内存使用方面是不是最理想的?

时间:2014-01-14 22:35:09

标签: c++ templates c++11 lambda functor

为了便于说明,假设我想实现一个通用的整数比较函数。我可以想到一些定义/调用函数的方法。

(A)功能模板+仿函数

template <class Compare> void compare_int (int a, int b, const std::string& msg, Compare cmp_func) 
{
    if (cmp_func(a, b)) std::cout << "a is " << msg << " b" << std::endl;
    else std::cout << "a is not " << msg << " b" << std::endl;
}

struct MyFunctor_LT {
    bool operator() (int a, int b) {
        return a<b;
    }
};

这将是对此功能的几次调用:

MyFunctor_LT mflt;
MyFunctor_GT mfgt; //not necessary to show the implementation
compare_int (3, 5, "less than", mflt);
compare_int (3, 5, "greater than", mflt);

(B)功能模板+ lambdas

我们会像这样打compare_int

compare_int (3, 5, "less than", [](int a, int b) {return a<b;});
compare_int (3, 5, "greater than", [](int a, int b) {return a>b;});

(C)功能模板+ std :: function

相同的模板实现,调用:

std::function<bool(int,int)> func_lt = [](int a, int b) {return a<b;}; //or a functor/function
std::function<bool(int,int)> func_gt = [](int a, int b) {return a>b;}; 

compare_int (3, 5, "less than", func_lt);
compare_int (3, 5, "greater than", func_gt);

(D)原始“C风格”指针

实现:

void compare_int (int a, int b, const std::string& msg, bool (*cmp_func) (int a, int b)) 
{
 ...
}

bool lt_func (int a, int b) 
{
    return a<b;
}

调用:

compare_int (10, 5, "less than", lt_func); 
compare_int (10, 5, "greater than", gt_func);

根据这些方案,我们在每种情况下都有:

(A)将编译两个模板实例(两个不同的参数)并在内存中分配。

(B)我想说还会编译两个模板实例。每个lambda都是一个不同的类。如果我错了,请纠正我。

(C)只编译一个模板实例,因为模板参数总是相同的:std::function<bool(int,int)>

(D)显然我们只有一个实例。

对于这样一个天真的例子来说,它并没有什么不同。但是当处理数十个(或数百个)模板和许多仿函数时,编译时间和内存使用差异可能很大。

我们可以说在许多情况下(例如,当使用具有相同签名的太多仿函数时)std::function(甚至函数指针)必须优先于模板+原始仿函数/ lambdas吗?用std::function包装你的仿函数或lambda可能非常方便。

我知道std::function(函数指针也是)引入了开销。值得吗?

编辑。我使用以下宏和一个非常常见的标准库函数模板(std :: sort)做了一个非常简单的基准测试:

#define TEST(X) std::function<bool(int,int)>  f##X = [] (int a, int b) {return (a^X)<(b+X);}; \
std::sort (v.begin(), v.end(), f##X);

#define TEST2(X) auto f##X = [] (int a, int b) {return (a^X)<(b^X);}; \
std::sort (v.begin(), v.end(), f##X);

#define TEST3(X) bool(*f##X)(int, int) = [] (int a, int b) {return (a^X)<(b^X);}; \ 
std::sort (v.begin(), v.end(), f##X);

关于生成的二进制文件的大小(GCC at -O3)的结果如下:

  • 带有1个TEST宏实例的二进制文件:17009
  • 1 TEST2宏实例:9932
  • 1 TEST3宏实例:9820
  • 50 TEST宏实例:59918
  • 50 TEST2宏实例:94682
  • 50 TEST3宏实例:16857

即使我显示了数字,它也比定量基准更具质量。正如我们所期望的那样,基于std::function参数或函数指针的函数模板更好地(在大小方面)缩放,因为没有创建太多实例。我没有测量运行时内存使用情况。

至于性能结果(矢量大小为1000000个元素):

  • 50 TEST宏实例:5.75s
  • 50 TEST2宏实例:1.54s
  • 50 TEST3宏实例:3.20s

这是一个显着的差异,我们不能忽视std::function引入的开销(至少如果我们的算法包含数百万次迭代)。

5 个答案:

答案 0 :(得分:9)

正如其他人已经指出的那样, lambdas和函数对象很可能被内联,特别是如果函数的主体不太长。因此,它们在速度和内存使用方面可能比std::function方法更好。 如果可以内联函数,编译器可以更积极地优化代码。令人震惊的更好。 std::function因此而成为我的最后手段。

  

但是在处理数十个(或数百个)模板时,数量众多   仿函数,编译时间和内存使用量的差异都可以   显着的。

至于编译时间,只要你使用如图所示的简单模板,我就不会太担心它。 (如果你正在进行模板元编程,是的,那么你可以开始担心。)

现在,内存使用情况:编译时编译器还是运行时生成的可执行文件?对于前者,与编译时相同。对于后者:内联lamdas和函数对象是赢家。

  

我们可以在很多情况下说std::function(甚至是   函数指针)必须优先于模板+原始   仿函数/ lambda表达式?即包裹你的算子或lambda   std::function可能非常方便。

我不太确定如何回答这个问题。我无法定义“很多情况”

但是,我可以肯定地说,类型擦除是一种避免/减少模板引起的代码膨胀的方法,请参阅项目44:模板中与因子参数无关的代码 { {3}}。顺便说一下,std::function在内部使用类型擦除。所以是的,代码臃肿是一个问题。

  

我知道std :: function(函数指针也是)引入了开销。值得吗?

“想要速度?措施。” (Howard Hinnant)

还有一件事:通过函数指针的函数调用可以内联(甚至跨编译单元!)。这是一个证据:

#include <cstdio>

bool lt_func(int a, int b) 
{
    return a<b;
}

void compare_int(int a, int b, const char* msg, bool (*cmp_func) (int a, int b)) {
    if (cmp_func(a, b)) printf("a is %s b\n", msg);
    else printf("a is not %s b\n", msg);
}

void f() {
  compare_int (10, 5, "less than", lt_func); 
}

这是您的代码的略微修改版本。我删除了所有的iostream东西,因为它会使生成的程序集混乱。这是f()

的集合
.LC1:
    .string "a is not %s b\n"
[...]
.LC2:
    .string "less than"
[...]
f():
.LFB33:
    .cfi_startproc
    movl    $.LC2, %edx
    movl    $.LC1, %esi
    movl    $1, %edi
    xorl    %eax, %eax
    jmp __printf_chk
    .cfi_endproc

这意味着,gcc 4.7.2在lt_func处内联-O3。实际上,生成的汇编代码是最优的。

我还检查过:我将lt_func的实现移动到单独的源文件中并启用了链接时优化(-flto)。 GCC仍然愉快地通过函数指针内联调用!这是非常重要的,你需要一个高质量的编译器才能做到这一点。


仅供记录,并且您实际上感觉 std::function方法的开销:

此代码:

#include <cstdio>
#include <functional>

template <class Compare> void compare_int(int a, int b, const char* msg, Compare cmp_func) 
{
    if (cmp_func(a, b)) printf("a is %s b\n", msg);
    else printf("a is not %s b\n", msg);
}

void f() {
  std::function<bool(int,int)> func_lt = [](int a, int b) {return a<b;};
  compare_int (10, 5, "less than", func_lt); 
}

-O3(约140行)生成此程序集:

f():
.LFB498:
    .cfi_startproc
    .cfi_personality 0x3,__gxx_personality_v0
    .cfi_lsda 0x3,.LLSDA498
    pushq   %rbx
    .cfi_def_cfa_offset 16
    .cfi_offset 3, -16
    movl    $1, %edi
    subq    $80, %rsp
    .cfi_def_cfa_offset 96
    movq    %fs:40, %rax
    movq    %rax, 72(%rsp)
    xorl    %eax, %eax
    movq    std::_Function_handler<bool (int, int), f()::{lambda(int, int)#1}>::_M_invoke(std::_Any_data const&, int, int), 24(%rsp)
    movq    std::_Function_base::_Base_manager<f()::{lambda(int, int)#1}>::_M_manager(std::_Any_data&, std::_Function_base::_Base_manager<f()::{lambda(int, int)#1}> const&, std::_Manager_operation), 16(%rsp)
.LEHB0:
    call    operator new(unsigned long)
.LEHE0:
    movq    %rax, (%rsp)
    movq    16(%rsp), %rax
    movq    $0, 48(%rsp)
    testq   %rax, %rax
    je  .L14
    movq    24(%rsp), %rdx
    movq    %rax, 48(%rsp)
    movq    %rsp, %rsi
    leaq    32(%rsp), %rdi
    movq    %rdx, 56(%rsp)
    movl    $2, %edx
.LEHB1:
    call    *%rax
.LEHE1:
    cmpq    $0, 48(%rsp)
    je  .L14
    movl    $5, %edx
    movl    $10, %esi
    leaq    32(%rsp), %rdi
.LEHB2:
    call    *56(%rsp)
    testb   %al, %al
    movl    $.LC0, %edx
    jne .L49
    movl    $.LC2, %esi
    movl    $1, %edi
    xorl    %eax, %eax
    call    __printf_chk
.LEHE2:
.L24:
    movq    48(%rsp), %rax
    testq   %rax, %rax
    je  .L23
    leaq    32(%rsp), %rsi
    movl    $3, %edx
    movq    %rsi, %rdi
.LEHB3:
    call    *%rax
.LEHE3:
.L23:
    movq    16(%rsp), %rax
    testq   %rax, %rax
    je  .L12
    movl    $3, %edx
    movq    %rsp, %rsi
    movq    %rsp, %rdi
.LEHB4:
    call    *%rax
.LEHE4:
.L12:
    movq    72(%rsp), %rax
    xorq    %fs:40, %rax
    jne .L50
    addq    $80, %rsp
    .cfi_remember_state
    .cfi_def_cfa_offset 16
    popq    %rbx
    .cfi_def_cfa_offset 8
    ret
    .p2align 4,,10
    .p2align 3
.L49:
    .cfi_restore_state
    movl    $.LC1, %esi
    movl    $1, %edi
    xorl    %eax, %eax
.LEHB5:
    call    __printf_chk
    jmp .L24
.L14:
    call    std::__throw_bad_function_call()
.LEHE5:
.L32:
    movq    48(%rsp), %rcx
    movq    %rax, %rbx
    testq   %rcx, %rcx
    je  .L20
    leaq    32(%rsp), %rsi
    movl    $3, %edx
    movq    %rsi, %rdi
    call    *%rcx
.L20:
    movq    16(%rsp), %rax
    testq   %rax, %rax
    je  .L29
    movl    $3, %edx
    movq    %rsp, %rsi
    movq    %rsp, %rdi
    call    *%rax
.L29:
    movq    %rbx, %rdi
.LEHB6:
    call    _Unwind_Resume
.LEHE6:
.L50:
    call    __stack_chk_fail
.L34:
    movq    48(%rsp), %rcx
    movq    %rax, %rbx
    testq   %rcx, %rcx
    je  .L20
    leaq    32(%rsp), %rsi
    movl    $3, %edx
    movq    %rsi, %rdi
    call    *%rcx
    jmp .L20
.L31:
    movq    %rax, %rbx
    jmp .L20
.L33:
    movq    16(%rsp), %rcx
    movq    %rax, %rbx
    testq   %rcx, %rcx
    je  .L29
    movl    $3, %edx
    movq    %rsp, %rsi
    movq    %rsp, %rdi
    call    *%rcx
    jmp .L29
    .cfi_endproc

在性能方面,您想选择哪种方法?

答案 1 :(得分:8)

如果你将lambda绑定到std::function,那么你的代码将运行得更慢,因为它将不再是可内联的,调用通过函数指针和函数对象的创建可能需要堆分配,如果大小lambda(=捕获状态的大小)超过小缓冲区限制(等于GCC IIRC上的一个或两个指针的大小)。

如果你保留你的lambda,例如auto a = []{};,然后它将与内联函数一样快(可能更快,因为当作为参数传递给函数时,没有转换为函数指针。)

在启用优化(-O1或更高版本的测试中)编译时,lambda和内联函数对象生成的目标代码将为零。有时,编译器可能会拒绝内联,但通常只在尝试内联大型函数体时才会发生。

如果您想确定,可以随时查看生成的装配体。

答案 2 :(得分:2)

我将讨论天真发生的事情,以及常见的优化。

(A)功能模板+仿函数

在这种情况下,将会有一个仿函数,其类型和参数完整地描述了在调用()时会发生什么。传递给每个仿函数的template函数将有一个实例。

虽然仿函数的最小值为1个字节,并且必须在技术上复制,但副本是无操作的(甚至不需要复制1个字节的空间:一个哑编译器/低优化设置可能会导致它无论如何都被复制了。)

优化编译器对象的存在对编译器来说很容易:方法是inline并且具有相同的符号名称,所以这甚至会发生在多个编译单元上。内联电话也很容易。

如果你有多个具有相同实现的函数对象,尽管如此需要花费一些函数,但是有些编译器可以做到这一点。内联template函数也很容易。在您的玩具示例中,由于输入在编译时是已知的,因此可以在编译时评估分支,消除死代码,并将所有内容简化为单个std::cout调用。

(B)功能模板+ lambdas

在这种情况下,每个lambda都是它自己的类型,每个闭包实例都是该lambda的未定义大小实例(通常为1个字节,因为它什么都不捕获)。如果定义相同的lambda并在不同的位置使用,则这些是不同的类型。函数对象的每个调用位置都是template函数的独特实例。

删除1字节闭包的存在(假设它们是无状态的)很容易。内联它们也很容易。删除具有相同实现但不同签名的template函数的重复实例更难,但某些编译器会这样做。内联所述功能并不比上面更难。在您的玩具示例中,由于输入在编译时是已知的,因此可以在编译时评估分支,消除死代码,并将所有内容简化为单个std::cout调用。

(C)功能模板+ std :: function

std::function是类型擦除对象。具有给定签名的std::function实例与另一个具有相同的类型。但是,std::function的构造函数是template d传入的类型。在您的情况下,您传入一个lambda - 所以每个位置用lambda初始化std::function生成一个独特的std::function构造函数,它会执行未知代码。

std::function的典型实现将使用pImpl模式将指向抽象接口的指针存储到包装可调用对象的辅助对象,并知道如何使用它来复制/移动/调用它。 std::function签名。每种类型创建一个这样的可调用类型std::function是根据它构造的每个std::function签名构建的。

将创建一个函数实例,取std::function

有些编译器可以注意到重复的方法并对两者使用相同的实现,并且可能为(很多)virtual函数表提取类似的技巧(但不是全部,因为动态转换要求它们不同) 。与早期的重复功能消除相比,这种情况发生的可能性较小。 std::function帮助程序使用的重复方法中的代码可能比其他重复函数更简单,因此可能更便宜。

虽然template函数可以是inline d,但我不知道可以优化std::function的存在的C ++编译器,因为它们通常被实现为库解决方案由编译器的相对复杂和不透明的代码组成。因此,虽然理论上可以对所有信息进行评估,但实际上std::function不会内联到template函数中,也不会消除死代码。两个分支都会将它组成生成的二进制文件,以及一堆std::function样板本身及其助手。

调用std::function与调用virtual方法一样昂贵 - 或者,根据经验,与两个函数指针调用一样昂贵。

(D)Raw&#34; C-style&#34;指针

创建一个函数,获取其地址,该地址传递给compare_int。然后它取消引用该指针以找到实际的函数,并调用它。

有些编译器擅长注意函数指针是从文字创建的,然后在此处内联调用:并非所有编译器都可以执行此操作,并且在一般情况下没有或很少编译器可以执行此操作。如果它们不能(因为初始化不是来自文字,因为接口是在一个编译单元而实现是在另一个中),跟随数据的函数指针有很大的代价 - 计算机往往是无法缓存它的位置,因此存在管道停滞。

请注意,您可以调用原始&#34; C风格&#34;无状态lambda的指针,无状态lambdas隐式转换为函数指针。另请注意,此示例严格弱于其他示例:它不接受有状态函数。具有相同功能的版本将是C风格的函数,它同时采用{​​{1}}和int状态。

答案 3 :(得分:1)

你应该使用带有lambda函数的auto关键字,而不是std :: function。这样你就可以获得独特的类型,并且没有std :: function的运行时开销。

另外,正如 dyp 所示,无状态(即没有捕获)lamba函数可以转换为函数指针。

答案 4 :(得分:0)

在A,B和C中,您可能最终得到一个不包含比较器的二进制文件,也不包含任何模板。它将字面上内联所有的比较,甚至可能删除打印的非真实分支 - 实际上,它将“放置”调用实际结果,而不做任何检查。

在D中,您的编译器无法做到这一点。

相反,在这个例子中它更优。它也更灵活 - 一个std :: function可以隐藏存储的成员,或者它只是一个普通的C函数,或者它是一个复杂的对象。它甚至允许您从类型的各个方面派生实现 - 如果您可以对POD类型进行更有效的比较,您可以实现它,并在其余部分忘记它。

以这种方式思考 - A和B是更高级的抽象实现,允许您告诉编译器“这个代码可能最好分别为每种类型实现,所以当我使用它时这样做”。 C是一个说“复杂的多个比较运算符,但它们都看起来像这样只做一个compare_int函数的实现”。在D你告诉它“不要打扰,只要让我这些功能。我最了解。”没有一个明显优于其他人。