【问题标题】:maximum of 3 values, performance of left-associative version vs. right-associative version最多 3 个值,左关联版本与右关联版本的性能
【发布时间】:2016-04-05 04:52:39
【问题描述】:

以下代码显示了我机器上min_3 的两个版本(Windows 7,VC++ 2015,发行版)的巨大性能差异。

#include <algorithm>
#include <chrono>
#include <iostream>
#include <random>

template <typename X>
const X& max_3_left( const X& a, const X& b, const X& c )
{
    return std::max( std::max( a, b ), c );
}

template <typename X>
const X& max_3_right( const X& a, const X& b, const X& c )
{
    return std::max( a, std::max( b, c ) );
}

int main()
{
    std::random_device r;
    std::default_random_engine e1( r() );
    std::uniform_int_distribution<int> uniform_dist( 1, 6 );
    std::vector<int> numbers;
    for ( int i = 0; i < 1000; ++i )
        numbers.push_back( uniform_dist( e1 ) );

    auto start1 = std::chrono::high_resolution_clock::now();
    int sum1 = 0;
    for ( int i = 0; i < 1000; ++i )
        for ( int j = 0; j < 1000; ++j )
            for ( int k = 0; k < 1000; ++k )
                sum1 += max_3_left( numbers[i], numbers[j], numbers[k] );
    auto finish1 = std::chrono::high_resolution_clock::now();
    std::cout << "left  " << sum1 << " " <<
        std::chrono::duration_cast<std::chrono::microseconds>(finish1 - start1).count()
        << " us" << std::endl;

    auto start2 = std::chrono::high_resolution_clock::now();
    int sum2 = 0;
    for ( int i = 0; i < 1000; ++i )
        for ( int j = 0; j < 1000; ++j )
            for ( int k = 0; k < 1000; ++k )
                sum2 += max_3_right( numbers[i], numbers[j], numbers[k] );
    auto finish2 = std::chrono::high_resolution_clock::now();
    std::cout << "right " << sum2 << " " <<
        std::chrono::duration_cast<std::chrono::microseconds>(finish2 - start2).count()
        << " us" << std::endl;
}

输出:

left  739861041 796056 us
right 739861041 1442495 us

ideone 上,差异较小但仍不可忽略。

为什么会存在这种差异?

【问题讨论】:

  • 尝试比较编译器的汇编输出。
  • g++ 5.2.1 -O2 上的类似结果
  • 你试过先右后左,看看时间是否不变?
  • 可能不是答案,但编译器可能会优化所有循环,因为您的代码有效地计算 sum1=max_3_left(numbers[999], numbers[999], numbers[999]); 和,即 sum1=numbers[999];。如果您将sum1= 更改为sum1+=sum2= 会怎样?
  • @DanielLangr:感谢您的评论。我现在将代码从sum = 更改为sum +=。差别还是一样的。

标签: c++ performance visual-c++


【解决方案1】:

gcc 和 clang(可能是 MSVC)没有意识到 max 是一个类似加法的关联操作。 v[i] max (v[j] max v[k]) (max_3_right) 与 (v[i] max v[j]) max v[k] (max_3_left) 相同。我写max 作为中缀运算符来指出与+ 和其他关联操作的相似之处。

由于v[k] 是唯一在内循​​环内部发生变化的输入,因此将(v[i] max v[j]) 提升出内循环显然是一个巨大的胜利。


要查看实际发生的情况,我们一如既往地必须查看 asm。为了便于查找循环的 asm,I split them out into separate functions。 (使用max3 函数作为参数制作一个模板函数会更像C++)。这还有一个额外的好处,就是从mainwhich gcc marks as "cold", disabling some optimizations 中提取我们想要优化的代码。

#include <algorithm>
#define SIZE 1000
int sum_maxright(const std::vector<int> &v) {
    int sum = 0;
    for ( int i = 0; i < SIZE; ++i )
        for ( int j = 0; j < SIZE; ++j )
            for ( int k = 0; k < SIZE; ++k )
                sum += max_3_right( v[i], v[j], v[k] );
    return sum;
}  

最里面的循环编译为(gcc 5.3 以 x86-64 Linux ABI 为目标,-std=gnu++11 -fverbose-asm -O3 -fno-tree-vectorize -fno-unroll-loops -march=haswell 带有一些手写注释)

## from outer loops: rdx points to v[k] (starting at v.begin()).  r8 is v.end().  (r10 is v.begin)
## edi is v[i], esi is v[j]
## eax is sum

 ## inner loop.  See the full asm on godbolt.org, link below
.L10:
        cmp     DWORD PTR [rdx], esi      # MEM[base: _65, offset: 0], D.92793
        mov     ecx, esi                  # D.92793, D.92793
        cmovge  ecx, DWORD PTR [rdx]      # ecx = max(v[j], v[k])
        cmp     ecx, edi      # D.92793, D.92793
        cmovl   ecx, edi      # ecx = max(ecx, v[i])
        add     rdx, 4    # pointer increment
        add     eax, ecx  # sum, D.92793
        cmp     rdx, r8   # ivtmp.253, D.92795
        jne     .L10      #,

Clang 3.8 为 max_3_right 循环编写了类似的代码,在内循环中有两条 cmov 指令。 (使用 Godbolt Compiler Explorer 中的编译器下拉菜单查看。)


gcc 和 clang 都优化了您对 max_3_left 循环的期望方式,将除单个 cmov 之外的所有内容提升到内部循环之外。

## register allocation is slightly different here:
## esi = max(v[i], v[j]).    rdi = v.end()
.L2:
        cmp     DWORD PTR [rdx], ecx      # MEM[base: _65, offset: 0], D.92761
        mov     esi, ecx  # D.92761, D.92761
        cmovge  esi, DWORD PTR [rdx]        # MEM[base: _65, offset: 0],, D.92761
        add     rdx, 4    # ivtmp.226,
        add     eax, esi  # sum, D.92761
        cmp     rdx, rdi  # ivtmp.226, D.92762
        jne     .L2       #,

所以在这个循环中发生的事情要少得多。 (在 Intel 之前的 Broadwell 上,cmov 是一条 2 微指令,因此少一条 cmov 很重要。)


顺便说一句,缓存预取效果无法解释这一点

  • 内部循环依次访问numbers[k]。对numbers[i]numbers[j] 的重复访问被任何体面的编译器从内部循环中提升出来,即使现代预取器不是这样,也不会混淆它们。

    Intel's optimization manual 表示对于 Sandybridge 系列微架构(2.3.5.4 数据预取部分)。

    OP 完全没有说明他在什么硬件上运行这个微基准测试,但由于真正的编译器会提升其他负载,只留下最微不足道的访问模式,这并不重要。

  • 1000 个 ints (4B) 中的一个 vector 只需要 4kiB。这意味着整个阵列很容易适应 L1D 缓存,因此首先不需要任何类型的预取。它几乎一直在 L1 缓存中保持热度。

【讨论】:

    【解决方案2】:

    正如 molbdnilo 指出的那样,问题可能出在循环的顺序上。在计算sum1时,代码可以改写为:

    for ( int i = 0; i < 1000; ++i )
       for ( int j = 0; j < 1000; ++j ) {
          auto temp = std::max(numbers[i], numbers[j]);
          for ( int k = 0; k < 1000; ++k )
                sum1 += std::max(temp, numbers[k]);
       }
    

    sum2的计算也不能这样。但是,当我将第二个循环重新排序为:

    for ( int j = 0; j < 1000; ++j )
       for ( int k = 0; k < 1000; ++k )
          for ( int i = 0; i < 1000; ++i )
             sum2 += ...;
    

    两次计算的时间相同。 (此外,-O3 的两种计算都比-O2 快得多。前者似乎根据反汇编输出打开了矢量化。)

    【讨论】:

    • 你为什么yada yada sum2 += ?
    • 您的意思是矢量化,而不是虚拟化:P 但是,-O3 启用 -ftree-vectorize-O2 没有。
    【解决方案3】:

    这与硬件级别的数据cache prefetching 有关。

    如果您使用左关联版本,则数组元素会按照 CPU 缓存预期的顺序使用/加载,延迟更少。

    正确的关联版本会破坏预测并且会产生更多的缓存未命中,因此会降低性能。

    【讨论】:

    • @VladFeinstein 这将教我查看输出中的位数。喝更多的咖啡。
    • 这是否是将索引顺序从 i,j,k 更改为 k,j,i 也会“翻转”时间的原因? (至少对我有用。)
    • 将第二个循环的循环顺序从 (i,j,k) 更改为 (j,k,i) 两次计算的时间几乎相同 (g++ 4.9.3)。使用-O2,左侧变体仍然快一点,但使用-O3(根据反汇编使用矢量化),右侧变体快一点。
    • 这可能是一个全新的问题,但我强烈认为编译器应该能够意识到缓存后果并对其进行优化;除非 max 本身是一个内在的,
    • @CaptainGiraffe:这个答案是不正确的,但正确的答案仍然取决于编译器是愚蠢的,并且在内联 STL max 函数后没有完全优化。
    猜你喜欢
    • 2012-07-12
    • 2017-08-21
    • 2014-02-11
    • 1970-01-01
    • 2011-03-12
    • 1970-01-01
    • 1970-01-01
    • 2016-05-30
    • 1970-01-01
    相关资源
    最近更新 更多