C++ 优化 if/else 条件

C++ Optimize if/else condition

本文关键字:else 条件 if 优化 C++      更新时间:2023-10-16

我有一行代码,占用了应用程序运行时的 25% - 30%。它是 std::set 的小于比较器(该集合是用红-黑树实现的)。 它在28秒内被调用约1.8亿次。

struct Entry {
const float _cost;
const long _id;
// some other vars
Entry(float cost, float id) : _cost(cost), _id(id) {
} 
};

template<class T>
struct lt_entry: public binary_function <T, T, bool>
{
bool operator()(const T &l, const T &r) const
{
// Most readable shape
if(l._cost != r._cost) {
return r._cost < l._cost;
} else {
return l._id < r._id;
}
}
};

条目应按成本排序,如果成本相同,则应按其 ID 排序。 对于最小值的每个提取,我都有很多插入。我想过使用斐波那契堆,但我被告知它们在理论上很好,但常数很高,实现起来非常复杂。由于插入在 O(log(n)) 中,因此对于较大的 n 来说,运行时间的增加几乎是恒定的。所以我认为坚持片场是可以的。

为了提高性能,我尝试用不同的形状来表达它:

return l._cost < r._cost || r._cost > l._cost || l._id < r._id;
return l._cost < r._cost || (l._cost == r._cost && l._id < r._id);

即使这样:

typedef union {
float _f;
int _i;
} flint;
//...
flint diff;
diff._f = (l._cost - r._cost);
return (diff._i && diff._i >> 31) || l._id < r._id;

但是编译器似乎已经足够聪明了,因为我无法改进运行时。

我也想到了SSE,但这个问题真的不太适用于SSE...

程序集看起来有点像这样:

movss  (%rbx),%xmm1
mov    $0x1,%r8d
movss  0x20(%rdx),%xmm0
ucomiss %xmm1,%xmm0
ja     0x410600 <_ZNSt8_Rb_tree[..]+96>
ucomiss %xmm0,%xmm1
jp     0x4105fd <_ZNSt8_Rb_[..]_+93>
jne    0x4105fd <_ZNSt8_Rb_[..]_+93>
mov    0x28(%rdx),%rax
cmp    %rax,0x8(%rbx)
jb     0x410600 <_ZNSt8_Rb_[..]_+96>
xor    %r8d,%r8d

我对汇编语言有一点点经验,但不是很多。

我认为挤出一些性能是最好的(唯一?)点,但这真的值得付出努力吗?你能看到任何可以节省一些周期的快捷方式吗?

代码将运行的平台是 ubuntu 12,在众核英特尔机器上装有 gcc 4.6 (-stl=c++0x)。只有可用的库是boost,openmp和tbb。30 秒基准测试是在我 4 年的旧笔记本电脑(酷睿 2 二重奏)上执行的。

我真的被困在这个上面,它看起来很简单,但需要那么多时间。几天来我一直在嘎吱嘎吱地想着如何改善这条线......

你能给我一个建议如何改进这部分,还是已经处于最佳状态?

编辑1:使用Jerrys的建议后,我实现了~4.5秒的速度。 编辑 2:在尝试提升斐波那契堆后,比较了 174 次 Mio 调用小于函数。

让我先说一下,我将在这里概述的内容是脆弱的,并非完全可移植 - 但在适当的情况下(这几乎是你指定的),我有理由确定它应该正常工作。

它所依赖的一点是,IEEE浮点数经过精心设计,因此,如果您将它们的位模式视为整数,它们仍然会按正确的顺序排序(模化一些东西,例如NaN,实际上没有"正确的顺序")。

为了利用这一点,我们要做的是打包条目,这样构成我们密钥的两个部分之间就没有填充。然后,我们确保整个结构与 8 字节边界对齐。我还将_id更改为int32_t,以确保它保持 32 位,即使在 64 位系统/编译器上也是如此(这几乎肯定会为这种比较生成最佳代码)。

然后,我们强制转换结构的地址,以便我们可以将浮点数和整数一起作为单个 64 位整数查看。由于您使用的是小端序处理器,为了支持这一点,我们需要将不太重要的部分(id)放在第一位,将更重要的部分(cost)放在第二位,因此当我们将它们视为 64 位整数时,浮点部分将成为最高有效位,整数部分将成为不太有效的位:

struct __attribute__ ((__packed__)) __attribute__((aligned(8)) Entry {
// Do *not* reorder the following two fields or comparison will break.
const int32_t _id;
const float _cost;
// some other vars
Entry(long id, float cost) : _cost(cost), _id(id) {} 
};

然后我们有了丑陋的小比较函数:

bool operator<(Entry const &a, Entry const &b) { 
return *(int64_t const *)&a < *(int64_t const *)&b;
}

一旦我们正确定义了结构,比较就变得相当简单:只需取每个结构的前 64 位,并将它们作为 64 位整数进行比较。

最后,一些测试代码至少可以保证它在某些值下可以正常工作:

int main() { 
Entry a(1236, 1.234f), b(1234, 1.235f), c(1235, 1.235f);
std::cout << std::boolalpha;
std::cout << (b<a) << "n";
std::cout << (a<b) << "n";
std::cout << (b<c) << "n";
std::cout << (c<b) << "n";
return 0;
}

至少对我来说,这会产生预期的结果:

false
true
true
false

现在,一些可能的问题:如果这两个项目在它们之间重新排列,或者结构的任何其他部分放在它们之前或之间,比较肯定会中断。其次,我们完全依赖于剩余的 32 位项目的大小,因此当它们连接起来时,它们将是 64 位。第三,如果有人从结构定义中删除__packed__属性,我们最终可能会在_id_cost之间填充,再次破坏比较。同样,如果有人删除了aligned(8),代码可能会失去一些速度,因为它试图加载与8字节边界不一致的8字节数量(在另一个处理器上,这可能会完全失败)。[编辑:哎呀。 @rici让我想起了我打算在这里列出的东西,但忘记了:这只有在_idcost都是正数时才正常工作。如果_cost为负数,则比较将被IEEE浮点使用有符号幅度表示的事实所打乱。如果_id为负数,则其符号位将被视为数字中间的普通位,因此负_id将显示为大于正_id

总而言之:这是脆弱的。对此完全没有疑问。尽管如此,它应该非常快 - 特别是如果你使用的是64位编译器,在这种情况下,我希望比较结果为两个加载和一个比较。长话短说,您可能根本无法使比较本身更快 - 您所能做的就是尝试并行执行更多操作,优化内存使用模式等。

一个简单的解决方案是预先计算一个排序标识符,该标识符由最重要的成本和其余的 id 组成。

例如,

struct Entry
{
double cost_;
long id_;
long long sortingId_;
// some other vars
Entry( double cost, float id )
: cost_( cost ), id_( id ), sortingId_( 1e9*100*cost + id )
{} 
};

根据您可以假设的值范围调整sortingId_值。

然后,现在只需对sortingId_进行排序.


或者作为相同想法的变体,如果您无法对数据做出合适的假设,请考虑专门为memcmp准备数据。


对于更高级别的解决方案,请记住,std::set::insert具有带有提示参数的重载。如果数据已经按接近排序顺序排列,则可能会严重减少对比较器函数的调用次数。


您可能会考虑std::unordered_set是否足够? 即您是否需要按排序顺序列出数据。或者,如果排序只是元素插入的内部内容std::set


最后,对于其他读者(OP已经明确表示他知道这一点),请记住测量

我很难相信:

a) 比较功能在 30 秒内运行 1.8 亿次

b) 比较功能占用 25% 的 CPU 时间

都是真的。即使是Core 2 Duo也应该能够在不到一秒的时间内轻松运行1.8亿次比较(毕竟,声称它可以做12,000 MIPS之类的事情,如果这真的意味着什么的话)。所以我倾向于相信还有其他东西被归入分析软件的比较中。(例如,为新元素分配内存。

但是,您至少应该考虑 std::set 不是您要查找的数据结构的可能性。如果您在实际需要排序值(或最大值,甚至)之前执行数百万次插入,那么最好将这些值放入向量中,这是一种在时间和空间上都便宜得多的数据结构,并根据需要对其进行排序。

如果你真的因为担心碰撞而需要这个集合,那么你可以考虑一个unordered_set,它稍微便宜一些,但不如矢量便宜。(正是因为向量不能保证你的唯一性。但老实说,看看这个结构定义,我很难相信独特性对你很重要。

"基准">

在我的小酷睿i5笔记本电脑上,我想它与OP的机器不在同一联盟中,我运行了一些测试,将1000万个随机的唯一条目(只有两个比较字段)插入到std::set和std::vector中。最后,我对向量进行排序。

我这样做了两次;一次是使用产生可能独特成本的随机生成器,一次是使用产生两种不同成本的生成器(这应该使比较速度变慢)。一千万次插入的结果比OP报告的略多。

unique cost         discrete cost
compares     time    compares     time
set       243002508    14.7s   241042920    15.6s   
vector    301036818     2.0s   302225452     2.3s

为了进一步隔离比较时间,我使用 std::sort 和 std::p artial_sort 重做了向量基准测试,使用了 10 个元素(基本上是前 10 个元素的选择)和 10% 的元素(即 100 万个)。较大partial_sort的结果让我感到惊讶 - 谁会认为对向量的10%进行排序会比对所有向量进行排序慢 - 但它们表明算法成本比比较成本重要得多:

unique cost         discrete cost
compares     time    compares     time
partial sort 10   10000598     0.6s    10000619     1.1s
partial sort 1M   77517081     2.3s    77567396     2.7s
full sort        301036818     2.0s   302225452     2.3s   

结论:比较时间越长是可见的,但容器操作占主导地位。在总共 52 秒的计算时间内,1000 万次设置插入的总成本肯定是显而易见的。一千万个载体插入的总成本就不那么明显了。

小纸条,值得一提:

我从那段汇编代码中得到的一件事是,你不会通过使成本成为float来节省任何东西。它实际上是为浮点数分配八个字节,因此您不会节省任何内存,并且您的 CPU 执行单个浮点比较的速度不会比单个双精度比较快。只是说'(即,当心过早优化)。

反对者,愿意解释吗?

对于最小值的每个提取,我都有很多插入。我想过使用斐波那契堆,但我被告知它们在理论上很好,但常数很高,实现起来非常复杂。由于插入在 O(log(n)) 中,因此对于较大的 n 来说,运行时间的增加几乎是恒定的。所以我认为坚持片场是可以的。

在我看来,这听起来像是一个典型的优先级队列应用程序。你说你刚刚考虑使用斐波那契堆,所以我想这样的优先级队列实现足以满足你的需求(推送元素,一次提取一个最小元素)。在你不遗余力地从比较函数中优化一个或两个时钟周期之前,我建议你尝试一些现成的优先级队列实现。像std::priority_queue一样,boost::d_ary_heap(或可变优先级队列的boost::d_ary_heap_indirect)或任何其他提升堆结构。

我之前遇到过类似的情况,我在类似 A* 的算法中使用std::set代替优先级队列(并且还尝试了带有std::inplace_merge的排序std::vector进行插入),切换到std::priority_queue是性能的巨大提升,然后切换到boost::d_ary_heap_indirect加倍努力。我建议你至少试一试,如果你还没有的话。

我本身没有答案 - 只有几个想法:

  1. 如果您使用的是 GCC,我会在启用并行模式的情况下运行一些基准测试
  2. 您确定没有处理成本构成的非规范化数字吗?