就内存使用而言,模板 + 仿函数/lambdas 不是最理想的吗?

Posted

技术标签:

【中文标题】就内存使用而言,模板 + 仿函数/lambdas 不是最理想的吗?【英文标题】:Are templates + functors/lambdas suboptimal in terms of memory usage? 【发布时间】:2014-02-03 05:11:14 【问题描述】:

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

(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&lt;bool(int,int)&gt;

(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.54 秒 50 个 TEST3 宏实例:3.20 秒

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

【问题讨论】:

如果是无状态的,可以将一个lambda转换为一个函数指针。只需使用一元魔法。 Lambda 是函子的语法糖; (A) 和 (B) 是相同的,不需要单独分析。 @Casey 我同意,但我不能 100% 确定两个不同的 lambda 是否意味着两个不同的模板参数。 您的 +Xsort 上的 UB,因为它不再是一个订单。尝试(a^X)&lt;(b^X) 以获得每个X 不同的明确定义的行为。另外,创建Test3,将其分配给无状态lambdas支持的bool(*)(int,int) 你是对的,我什至没有想到比较功能。由于模板实例化,我只是对测量代码大小感兴趣。无论如何,我用新功能更新了结果。它们大致相同。我还为函数指针添加了结果 (TEST3)。我认为结论是直截了当的。在内存受限的系统上工作并将超过 10-15 个具有相同签名的不同仿函数/lambdas 传递给模板?然后使用函数指针。但为了获得最佳性能,请始终使用“原始”函子/lambdas。 【参考方案1】:

正如其他人已经指出的那样,lambdas 和函数对象很可能是内联的,尤其是在函数体不太长的情况下。因此,它们在速度和内存使用方面可能比std::function 方法更好。 如果函数可以内联,编译器可以更积极地优化您的代码。好得令人震惊。出于这个原因,std::function 将是我最后的选择。

但是当使用数十个(或数百个)模板时, 函子,编译时间和内存使用差异可以是 实质性的。

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

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

我们可以说在许多情况下std::function(甚至 函数指针)必须优先于模板+原始 函子/lambda? IE。包装你的函子或 lambda std::function 可能很方便。

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

但是,我可以肯定地说,类型擦除是一种避免/减少由于模板引起的代码膨胀的方法,请参阅 Item 44: Factor-independent code out of templates in @ 987654321@。顺便说一句,std::function 在内部使用类型擦除。所以是的,代码膨胀是个问题。

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

“想要速度?测量。” (霍华德·欣南特)

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

#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 在-O3 内联lt_func。事实上,生成的汇编代码是最优的。

我还检查了:我将 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

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

【讨论】:

为什么 lambda 和仿函数在运行时内存使用方面是赢家? 100 个 lambda 意味着 100 个模板实例(即使所有仿函数或 lambda 具有相同的签名)。 100 std::function 意味着只有一个模板实例(或者实际上是两个,std::function 模板和您正在使用“包装”函数的模板)。我假设所有仿函数/lambdas/等都具有相同的签名,所以只有一个 std::function 模板实例化就足够了。 @jbgs 请重新阅读我的答案,尤其是我提到有效 C++ 第 44 条的段落。由于模板导致的代码膨胀问题是众所周知的;另见稍微过时的 12 岁讨论 Code Bloat Due to Templates。如果您有事实来支持代码膨胀对您来说是一个问题(例如您检查了汇编代码),那么类型擦除是一种方法。您可以通过传递std::function(大约 140 行汇编代码)或函数指针(5 行汇编代码)来实现。 @jbgs 这是一个非常粗略的估计:基于std::function() 方法的装配长度:你必须有 (140/5=)28模板实例化以生成相同数量的汇编代码。当然,这只是一个粗略且相当可疑的估计,但它仍然表明std::function() 在生成代码和执行时间方面都非常昂贵。因此,即使 如果 你得到了代码膨胀(如果你得到它的话),它也需要许多模板实例化,直到 std::function() 变得更可取。但正如我之前所说,代码膨胀是一个已知问题。 @jbgs 好的,我看到你做了一些测量。好的。这与我刚刚写的一致。【参考方案2】:

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

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

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

如果您想确定,您可以随时查看生成的程序集。

【讨论】:

这是我的意图。在 (C) 中,我只想编译模板的一个实例。如果我使用自动会发生什么?我会说我们会有两个不同的模板参数。如果我错了,请纠正我。 每个 lambda 都有唯一的类型,这可能会稍微增加构建成本。但我认为这不会很重要。 (构建速度慢通常是由于包含过多的头文件或进行了密集的编译时间计算。)注意:这个成本只影响构建时间,而不影响运行速度。 我同意,但这就是我想比较的:) 对于这个幼稚的例子来说,这并不重要。但它可能在某些情况下(多次编译的内存使用与原始指针或std::function 引入的开销)。 请记住,包含 &lt;functional&gt; 并将 lambda 绑定到 std::function 也会产生编译时成本。 看the assembly code of this little program。您在输出中看到的唯一数字是11111,因为编译器推断这是答案。其他号码都没有了。也没有为 lambdas 生成目标代码。没有开销。【参考方案3】:

我将简单地讨论发生了什么,以及常见的优化。

(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 的构造函数在传入的类型上是 templated。在您的情况下,您传递的是一个 lambda - 因此,您使用 lambda 初始化 std::function 的每个位置都会生成一个不同的 std::function构造函数,执行未知代码。

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

将创建函数的一个实例,采用std::function

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

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

调用std::function 与调用virtual 方法的成本大致相同——或者根据经验,与调用两个函数指针的成本大致相同。

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

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

一些编译器擅长注意到函数指针是从文字创建的,然后在这里内联调用:并非所有编译器都可以这样做,在一般情况下没有或很少有编译器可以这样做。如果它们不能(因为初始化不是来自文字,因为接口在一个编译单元中,而实现在另一个编译单元中),那么遵循指向数据的函数指针会付出巨大的代价——计算机往往无法缓存它要去的位置,所以有一个管道停顿。

请注意,您可以使用无状态 lambda 调用原始的“C 样式”指针,因为无状态 lambda 会隐式转换为函数指针。另请注意,此示例比其他示例严格弱:它不接受有状态函数。具有相同功能的版本将是一个 C 风格的函数,它采用 ints 和 void* 状态对。

【讨论】:

我刚刚检查过:我将比较器移动到一个单独的源文件(cpp 文件),并且在调用站点上只有声明可用。当我启用链接时间优化 (-flto) 时,GCC 愉快地通过函数指针内联了调用。但我同意这是不平凡的,你需要一个高质量的编译器来做到这一点。幸运的是,我们有一个。【参考方案4】:

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

此外,正如 dyp 所建议的,无状态(即无捕获)lamba 函数可以转换为函数指针。

【讨论】:

我在使用std::function 时没有得到唯一的类型吗?两种情况的模板参数相同。 如果你想最小化内存使用(一个模板实例),将 lambda 转换为函数指针(或使用 C 风格的函数指针 - 没有区别)。【参考方案5】:

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

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

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

这样想 - A 和 B 是更高的抽象实现,允许您告诉编译器“此代码可能最好分别为每种类型实现,所以当我使用它时为我这样做”。 C 是这样一种说法:“会有多个比较运算符很复杂,但它们都看起来像这样,所以只实现 compare_int 函数的一个实现”。在 D 中,您告诉它“不要打扰,只需为我创建这些功能即可。我最了解。”没有一个比其他的更好。

【讨论】:

这是一个幼稚的例子。我只是用它来说明四种方法。显然很多东西都可以优化掉。 您正试图做出一个过于笼统的陈述,这显然不是真的。阅读我回复中的最后一段,为什么不呢。 我读过。当然,最好的方法取决于您的具体情况。但也许我们可以提出一些通用的指导方针。 你能做的最好的事情是,如果苍蝇拍就够了,就不要使用战术核武器。当函数指针足以满足您需要的变化时,不要使用 std::function。 Dascandy,你能帮我找到可以优化 C 语言中的 std::function 的编译器吗?

以上是关于就内存使用而言,模板 + 仿函数/lambdas 不是最理想的吗?的主要内容,如果未能解决你的问题,请参考以下文章

仿函数和模版的模板参数

模板参数推导与 QT lambda 不匹配

如何为我的 Lambda 函数创建可重复使用的 CloudFormation 模板?

将模板函数转换为通用 lambda

lambda函数

Kotlin函数 ⑦ ( 内联函数 | Lambda 表达式弊端 | “ 内联 “ 机制避免内存开销 - 将使用 Lambda 表达式作为参数的函数定义为内联函数 | 内联函数本质 - 宏替换 )