【问题标题】:Performance penalty of using functor to provide a function or an operator as a C++ template parameter?使用仿函数提供函数或运算符作为 C++ 模板参数的性能损失?
【发布时间】:2012-03-07 15:54:35
【问题描述】:

我有一系列复杂的函数,它们执行非常相似的任务,只是函数中间有一个操作员。我的代码的简化版本可能是这样的:

#include <assert.h>

static void memopXor(char * buffer1, char * buffer2, char * res, unsigned n){
    for (unsigned x = 0 ; x < n ; x++){
        res[x] = buffer1[x] ^ buffer2[x];
    }
};

static void memopPlus(char * buffer1, char * buffer2, char * res, unsigned n){
    for (unsigned x = 0 ; x < n ; x++){
        res[x] = buffer1[x] + buffer2[x];
    }
};

static void memopMul(char * buffer1, char * buffer2, char * res, unsigned n){
    for (unsigned x = 0 ; x < n ; x++){
        res[x] = buffer1[x] * buffer2[x];
    }
};


int main(int argc, char ** argv){
    char b1[5] = {0, 1, 2, 3, 4};
    char b2[5] = {0, 1, 2, 3, 4};

    char res1[5] = {};
    memopXor(b1, b2, res1, 5);

    assert(res1[0] == 0);
    assert(res1[1] == 0);
    assert(res1[2] == 0);
    assert(res1[3] == 0);
    assert(res1[4] == 1);

    char res2[5] = {};
    memopPlus(b1, b2, res2, 5);

    assert(res2[0] == 0);
    assert(res2[1] == 2);
    assert(res2[2] == 4);
    assert(res2[3] == 6);
    assert(res2[4] == 8);

    char res3[5] = {};
    memopMul(b1, b2, res3, 5);

    assert(res3[0] == 0);
    assert(res3[1] == 1);
    assert(res3[2] == 4);
    assert(res3[3] == 9);
    assert(res3[4] == 16);
}

使用 C++ 模板来避免重复代码看起来是一个很好的案例,因此我一直在寻找一种方法来将我的代码更改为如下所示的内容(伪代码):

#include <assert.h>

template <FUNCTION>
void memop<FUNCTION>(char * buffer1, char * buffer2, char * res, size_t n){
    for (size_t x = 0 ; x < n ; x++){
        res[x] = FUNCTION(buffer1[x], buffer2[x]);
    }
}

int main(int argc, char ** argv){
    char b1[5] = {0, 1, 2, 3, 4};
    char b2[5] = {0, 1, 2, 3, 4};

    char res1[5] = {};
    memop<operator^>(b1, b2, res1, 5);

    assert(res1[0] == 0);
    assert(res1[1] == 0);
    assert(res1[2] == 0);
    assert(res1[3] == 0);
    assert(res1[4] == 0);

    char res2[5] = {};
    memop<operator+>(b1, b2, res2, 5);

    assert(res2[0] == 0);
    assert(res2[1] == 2);
    assert(res2[2] == 4);
    assert(res2[3] == 6);
    assert(res2[4] == 8);

    char res3[5] = {};
    memop<operator*>(b1, b2, res3, 5);

    assert(res3[0] == 0);
    assert(res3[1] == 1);
    assert(res3[2] == 4);
    assert(res3[3] == 9);
    assert(res3[4] == 16);
}

难点是我不愿意接受结果代码的任何减速。这意味着暗示间接调用(通过 vtable 或函数指针)的解决方案是不行的。

这个问题的常见 C++ 解决方案似乎是将要调用的运算符包装在仿函数类的 operator() 方法中。通常会得到类似下面的代码:

#include <assert.h>

template <typename Op>
void memop(char * buffer1, char * buffer2, char * res, unsigned n){
    Op o;
    for (unsigned x = 0 ; x < n ; x++){
        res[x] = o(buffer1[x], buffer2[x]);
    }
};


struct Xor
{
    char operator()(char a, char b){
        return a ^ b;
    }
};

struct Plus
{
    char operator()(char a, char b){
        return a + b;
    }
};

struct Mul
{
    char operator()(char a, char b){
        return a * b;
    }
};

int main(int argc, char ** argv){
    char b1[5] = {0, 1, 2, 3, 4};
    char b2[5] = {0, 1, 2, 3, 4};

    char res1[5] = {};
    memop<Xor>(b1, b2, res1, 5);

    assert(res1[0] == 0);
    assert(res1[1] == 0);
    assert(res1[2] == 0);
    assert(res1[3] == 0);
    assert(res1[4] == 0);

    char res2[5] = {};
    memop<Plus>(b1, b2, res2, 5);

    assert(res2[0] == 0);
    assert(res2[1] == 2);
    assert(res2[2] == 4);
    assert(res2[3] == 6);
    assert(res2[4] == 8);

    char res3[5] = {};
    memop<Mul>(b1, b2, res3, 5);

    assert(res3[0] == 0);
    assert(res3[1] == 1);
    assert(res3[2] == 4);
    assert(res3[3] == 9);
    assert(res3[4] == 16);
}

这样做有什么性能损失吗?

【问题讨论】:

  • 只有您可以通过以下方式确定哪个在性能方面更好: 1. 分析 2. 比较和分析生成的汇编代码。这两个都在你的工作环境中。
  • 仿函数可能会被内联。
  • 如果您摆脱本地实例并将这些操作设为静态成员函数,编译器可能会更轻松地进行优化。然而,任何一种方式的代码都可以被优化掉(使用足够先进的编译器)。
  • @EthanSteinberg:正如我所展示的,这并不重要,只要函数调用是内联的,删除 this 参数就是一个简单的死存储消除。

标签: c++ templates


【解决方案1】:

就 bencharmk 而言,您公开的代码几乎毫无用处。

char cversion() {
    char b1[5] = {0, 1, 2, 3, 4};
    char b2[5] = {0, 1, 2, 3, 4};

    char res1[5] = {};
    memopXor(b1, b2, res1, 5);

    return res1[4];
}

char cppversion() {
    char b1[5] = {0, 1, 2, 3, 4};
    char b2[5] = {0, 1, 2, 3, 4};

    char res1[5] = {};
    memop<Xor>(b1, b2, res1, 5);

    return res1[4];
}

被编译成这样的 LLVM IR:

define signext i8 @cversion()() nounwind uwtable readnone {
  ret i8 0
}

define signext i8 @cppversion()() nounwind uwtable readnone {
  ret i8 0
}

也就是说,编译器在编译期间进行整个计算。

所以我冒昧地定义了一个新函数:

void cppmemopXor(char * buffer1,
                 char * buffer2,
                 char * res,
                 unsigned n)
{
  memop<Xor>(buffer1, buffer2, res, n);
}

并删除memopXor上的static限定符,然后重复体验:

define void @memopXor(char*, char*, char*, unsigned int)(i8* nocapture %buffer1, i8* nocapture %buffer2, i8* nocapture %res, i32 %n) nounwind uwtable {
  %1 = icmp eq i32 %n, 0
  br i1 %1, label %._crit_edge, label %.lr.ph

.lr.ph:                                           ; preds = %.lr.ph, %0
  %indvars.iv = phi i64 [ %indvars.iv.next, %.lr.ph ], [ 0, %0 ]
  %2 = getelementptr inbounds i8* %buffer1, i64 %indvars.iv
  %3 = load i8* %2, align 1, !tbaa !0
  %4 = getelementptr inbounds i8* %buffer2, i64 %indvars.iv
  %5 = load i8* %4, align 1, !tbaa !0
  %6 = xor i8 %5, %3
  %7 = getelementptr inbounds i8* %res, i64 %indvars.iv
  store i8 %6, i8* %7, align 1, !tbaa !0
  %indvars.iv.next = add i64 %indvars.iv, 1
  %lftr.wideiv = trunc i64 %indvars.iv.next to i32
  %exitcond = icmp eq i32 %lftr.wideiv, %n
  br i1 %exitcond, label %._crit_edge, label %.lr.ph

._crit_edge:                                      ; preds = %.lr.ph, %0
  ret void
}

以及带有模板的 C++ 版本:

define void @cppmemopXor(char*, char*, char*, unsigned int)(i8* nocapture %buffer1, i8* nocapture %buffer2, i8* nocapture %res, i32 %n) nounwind uwtable {
  %1 = icmp eq i32 %n, 0
  br i1 %1, label %_ZL5memopI3XorEvPcS1_S1_j.exit, label %.lr.ph.i

.lr.ph.i:                                         ; preds = %.lr.ph.i, %0
  %indvars.iv.i = phi i64 [ %indvars.iv.next.i, %.lr.ph.i ], [ 0, %0 ]
  %2 = getelementptr inbounds i8* %buffer1, i64 %indvars.iv.i
  %3 = load i8* %2, align 1, !tbaa !0
  %4 = getelementptr inbounds i8* %buffer2, i64 %indvars.iv.i
  %5 = load i8* %4, align 1, !tbaa !0
  %6 = xor i8 %5, %3
  %7 = getelementptr inbounds i8* %res, i64 %indvars.iv.i
  store i8 %6, i8* %7, align 1, !tbaa !0
  %indvars.iv.next.i = add i64 %indvars.iv.i, 1
  %lftr.wideiv = trunc i64 %indvars.iv.next.i to i32
  %exitcond = icmp eq i32 %lftr.wideiv, %n
  br i1 %exitcond, label %_ZL5memopI3XorEvPcS1_S1_j.exit, label %.lr.ph.i

_ZL5memopI3XorEvPcS1_S1_j.exit:                   ; preds = %.lr.ph.i, %0
  ret void
}

正如预期的那样,它们在结构上是相同的,因为函子代码已完全内联(即使不了解 IR 也可见)。

请注意,这不是孤立的结果。例如,std::sort 的执行速度是qsort 的两倍到三倍,因为它使用仿函数而不是间接函数调用。当然,使用模板化函数和仿函数意味着每个不同的实例化都会生成新代码,就好像您手动编写了函数一样,但这正是您手动执行的操作。

【讨论】:

  • 很明显,我公开的代码确实不是基准代码,而是单元测试代码,我希望一个足够好的编译器可以优化所有代码。
  • 但是好吧,如果我从汇编分析中理解正确的话,我的仿函数类足够简单,可以在编译器内联代码时完全消失。
【解决方案2】:

只有您可以判断某件事的速度是否足以满足您的需求。但我继续在我自己的盒子上运行你的代码,看看会发生什么。

int main(int argc, char ** argv){
  char b1[5] = {0, 1, 2, 3, 4};
  char b2[5] = {0, 1, 2, 3, 4};

  int ans = 0;

  for (int i = 0; i < 100000000; i++) {
    char res1[5] = {};
    memopXor(b1, b2, res1, 5);
//    memop<Xor>(b1, b2, res1, 5);

    char res2[5] = {};
    memopPlus(b1, b2, res2, 5);
//    memop<Plus>(b1, b2, res2, 5);

    char res3[5] = {};
    memopMul(b1, b2, res3, 5);
//    memop<Mul>(b1, b2, res3, 5);

    ans += res1[0] + res2[1] + res3[2];  // prevents optimization
  }

  std::cout << ans << std::endl;

  return 0;
}

我在 g++ 上使用 -O3 编译了两个版本。 time 手动编码版本返回 2.40s,模板版本返回 2.58s。

(顺便说一句,我必须更正您的 memopMul() 才能实际执行乘法运算。)

【讨论】:

  • 使用 Clang(和 printf)我明白了,tail call i32 (i8*, ...)* @printf(i8* getelementptr inbounds ([3 x i8]* @.str, i64 0, i64 0), i32 600000000) nounwind。也就是说,整个计算是在编译时完成的。您使用的是非常旧的g++ 版本吗?
  • @MatthieuM。是的,我家里有一个古老的 GCC。我很惊讶我的编译器的模板版本变慢了,尽管我怀疑更新的编译器可以正确处理优化。
  • 谢谢,我明白了。通常 gcc 和 Clang 差不多,Clang 在数值计算上领先,而 gcc 在更“常规”代码上领先(非常高的概述)。
【解决方案3】:

我在上面的代码中看到的唯一问题是编译器在调用 memop 时会遇到内存操作别名问题,请参阅:C++ aliasing rules。

还请记住,在模板版本中,编译器将为传入的每个唯一模板参数生成一个不同的对象,这意味着对于 memop 的三个调用,具有三个不同的操作,您将在二进制文件中获得三个实现。这应该会产生与您的原始代码几乎相同的代码。

我同意其他评论者的观点,即函数应该内联。如果性能很关键,您应该在建议的更改之前和之后对代码进行基准测试,为了安全起见,只需要错误的编译器标志就可以了。

【讨论】:

  • 我的示例代码确实存在内存别名问题,但它存在于模板化和非模板化代码中。我实际上做了一些基准测试(在 gcc 上),结果与答案中的预期一致。如果没有优化,模板版本会慢得多,打开 O3 标志根本没有区别。运行时间相同,甚至生成的汇编代码也完全相同。
  • 完全符合预期:) 用观察证实理论总是好的!好东西
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2013-06-23
  • 2018-02-17
  • 2019-11-13
  • 1970-01-01
  • 2020-09-19
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多