【问题标题】:C++ Optimize if/else conditionC++ 优化 if/else 条件
【发布时间】:2012-12-12 03:44:57
【问题描述】:

我有一行代码,占用了我的应用程序运行时间的 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 排序。 对于最小值的每次提取,我都有很多插入。我考虑过使用 Fibonacci-Heaps,但有人告诉我,它们在理论上很好,但常数很高,实现起来相当复杂。并且由于插入在 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 年的旧笔记本电脑(core 2 duo)上执行的。

我真的坚持这个,它看起来很简单,但需要很多时间。几天来我一直在思考如何改进这条线......

您能否给我一个建议如何改进这部分,或者它已经处于最佳状态?

编辑 1:使用 Jerrys 的建议后,我实现了约 4.5 秒的加速。 编辑 2:在尝试提升 Fibonacci 堆后,比较到 174 次 Mio 调用小于函数。

【问题讨论】:

  • 比较两个数字是您应用程序的瓶颈吗?哇...您在程序的其他部分几乎什么都没做。
  • 您应该考虑您的分析软件在撒谎的可能性,并且瓶颈在此操作的上下几行。
  • 顺便说一句,我认为 1.8*10^8 比较的 52s 是公平的数字。 RB-Tree 具有很大的隐藏常数,具有 P4 处理器的计算机每秒可以运行大约 10^7 次基本操作。
  • 你到底在说什么? IE。你是如何分析它的?是分支未命中预测还是缓存未命中?非对齐访问?我建议您首先使用 vtune 或 perf 工具来解决这个问题。然后想想怎么解决。例如,可能值得重组以使其缓存友好,甚至使其适合 L1(或 L2)。到目前为止,您肯定会在填充上浪费内存。请记住,由于其他代码,您也可能会看到副作用,即使 CPU 在这里很糟糕,也不一定意味着该代码有问题。
  • 我不知道速度,但是为了可读性,我喜欢std::tie:return std::tie(l._cost, l.id) &lt; std::tie(r._cost, r._id);

标签: c++ performance assembly


【解决方案1】:

一个简单的解决方案是预先计算一个排序标识符,其中包含最重要的成本和其余部分的 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 有一个带有 hint 参数的重载。如果您的数据已经接近排序,则可能会严重减少对比较器函数的调用次数。


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


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

【讨论】:

    【解决方案2】:

    我很难相信:

    a) 比较函数在 30 秒内运行 1.8 亿次

    和

    b) 比较函数使用 25% 的 cpu 时间

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

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

    如果您因为担心冲突而确实需要该集合,那么您可以考虑使用 unordered_set,它稍微便宜一些,但不如向量便宜。 (正是因为向量不能保证你的唯一性。)但老实说,看看那个结构定义,我很难相信唯一性对你很重要。

    “基准”

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

    我这样做了两次;一次使用产生可能独特成本的随机生成器,一次使用产生两种不同成本的生成器(这应该会使比较变慢)。 1000 万次插入的结果比 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::partial_sort 重新进行了向量基准测试,使用了 10 个元素(基本上是前 10 个元素的选择)和 10% 的元素(即是,一百万)。较大的 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 并没有节省任何东西。它实际上为浮点数分配了 8 个字节,因此您不会节省任何内存,而且您的 CPU 进行单次浮点比较的速度不会比单次双精度比较快。只是说'(即提防过早的优化)。

    否决者,愿意解释一下吗?

    【讨论】:

    • 感谢基准测试。实际上我搞砸了一点,代码必须执行的平台是多核英特尔机器(我认为它有 40 个内核),但我测量调用的地方是我 4 岁的旧笔记本电脑(core 2 duo)。而且我不小心拿错了时间,是 30 秒,而不是 52 秒。
    • @Heye,我认为这没什么区别。也许我的机器比你的机器快,但不是快一个数量级。如果我可以创建一个包含 1000 万个元素的向量,然后在两秒钟内对其进行排序(移动所有这些元素),那么比较甚至不需要一秒钟。假设他们在您的机器上花费了两秒钟。 30 人中有 2 人仍远低于 25%。但是使用set 的成本仍然存在。
    • 尝试将带有“foo bar”的 std::string 添加到您正在排序的结构中,我对 lt 函数进行了 4.8 次 Bio 调用。没有字符串 1 秒,有字符串 5.5 秒。使用@Jerry Coffins 的想法,它达到了 1.5 秒。当然,比较所需的时间非常短,但我设法挤出了大约 15% 的改进。虽然 4.8 Bio 远超过 180 Mio,但基准测试可能会以不同的方式进行优化,因此速度更快......
    • @Heye,Jerry 的布局在每个对象上为您节省了 8 个字节,这提高了局部性,但这意味着您的 id 从 64 位缩小到 32 位。这可以接受吗?如果是这样,你绝对应该去尝试它,但我会在没有别名黑客的情况下尝试它,看看它是如何进行的。我认为您所说的“添加 std::string”是指该字符串仅存在于结构中,不参与搜索;即便如此,结构会增长一定数量,这可能会导致任何数量的缓存效果,具体取决于您进行比较的方式。我选择用真正的容器来做,因为这样更真实。
    • @heye,如果您可以使用向量(甚至是 unordered_map),您可能会看到大约 50% 的改进。我的性能建议始终是“首先查看算法;然后查看数据结构;然后再尝试微观管理优化。”当然是 YMMV。
    【解决方案3】:

    让我先说明一下,我将在此处概述的内容很脆弱且不完全可移植——但在正确的情况下(几乎就是您指定的情况)我有理由确定它应该可以正常工作。

    这取决于一个事实,即 IEEE 浮点数经过精心设计,因此如果您将它们的位模式视为整数,它们仍然会按正确的顺序排序(模数像 NaN 之类的东西,对于真的没有“正确的顺序”)。

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

    然后,我们转换结构的地址,以便我们可以将浮点数和整数一起视为一个 64 位整数。由于您使用的是 little-endian 处理器,为了支持我们需要将不太重要的部分(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 让我想起了我打算在这里列出的东西,但忘记了:这只有在 _id 和 cost 都是肯定的情况下才能正常工作。如果_cost 为负数,则比较会因为IEEE 浮点使用有符号幅度表示而变得混乱。如果_id 为负数,则其符号位将被视为数字中间的普通位,因此负数_id 将显示为大于正数_id。]

    总结:这是脆弱的。对此毫无疑问。尽管如此,它应该很快——尤其是如果您使用的是 64 位编译器,在这种情况下,我希望比较结果是两次加载和一次比较。长话短说,您可能根本无法让比较本身变得更快——您所能做的就是尝试并行执行更多操作、优化内存使用模式等。

    【讨论】:

    • 一个问题:为什么我们需要将结构对齐到 8 字节边界?仅仅确保两块之间没有填充是不够的?
    • *(int64_t const *)&amp;a 导致未定义的行为(违反严格的别名规则)。
    • 哇,好主意!我设法实现了大约 15% 的加速。我正在使用一个仅包含 id 和成本的额外结构 Cmp,并将其作为第一个字段放入 Entry 结构中,因为当我在 Entry 结构中有 std::vector 或其他一些容器时,编译器会警告打包.但是这种方式表现得相当不错。 :)
    • @Nawaz:在英特尔处理器上,8 字节对齐并不是严格必要的(但可能会提高速度)。在某些处理器(主要是 RISC)上,尝试加载未与 8 字节边界对齐的 8 字节项目可能/将给出异常。
    • @AndreyVihrov:您认为它违反了哪些严格的别名规则? [提示:这是标记为 C++,不是 C99]。现实情况是,该行为并未由标准定义——但缺乏定义与严格的别名几乎没有关系,只要您编译为 C++。
    【解决方案4】:

    每次提取最小值时,我都有很多插入。我考虑过使用 Fibonacci-Heaps,但有人告诉我,它们在理论上很好,但常数很高,实现起来相当复杂。并且由于插入在 O(log(n)) 中,因此运行时间增加在 n 较大时几乎是恒定的。所以我认为坚持这个系列是可以的。

    这听起来像是典型的优先队列应用程序。您说您刚刚考虑使用斐波那契堆,所以我想这样的优先级队列实现足以满足您的需求(推送元素,并一次提取一个最小元素)。在您竭尽全力优化该比较功能的一两个时钟周期之前,我建议您尝试一些现成的优先级队列实现。比如std::priority_queue、boost::d_ary_heap(或boost::d_ary_heap_indirect 用于可变优先级队列)或any other boost heap structure。

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

    【讨论】:

    • 我现在正在尝试 d_ary_heap,但我在删除复制构造函数时遇到了一些编译问题:
    • 这些是我的构造函数:Entry(Entry &amp;&amp;e) : _cmp(e._cmp) {} 和Entry(Entry &amp;e) : _cmp(e._cmp) {}(缩短)其中_cmp 是@Jerry Coffin 建议的结构。 bh::d_ary_heap&lt;Entry, bh::arity&lt;2&gt;, bh::compare&lt;lt_entry&lt;Entry&gt; &gt; &gt; 它给了我:“Entry& Entry::operator=(const Entry&)' 被隐式声明为已删除,因为'Entry' 在 heap.pop() 操作上声明了一个移动构造函数或移动赋值运算符”。我一有时间就可以发布示例代码。
    【解决方案5】:

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

    1. 如果您使用 GCC,我会使用 parallel mode enabled 运行一些基准测试
    2. 您确定不处理成本组件的非规范化数字吗?

    【讨论】:

      猜你喜欢
      • 2017-09-19
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2019-12-08
      • 2022-01-24
      • 2017-03-16
      • 2020-11-23
      • 2023-03-28
      相关资源
      最近更新 更多