现在的位置: 首页 > 综合 > 正文

为什么处理一个排列数组比处理未排列数组更快?

2018年05月15日 ⁄ 综合 ⁄ 共 4237字 ⁄ 字号 评论关闭

这是在Stackoverflow的关于 “分支预测” 的经典问答。

形象地阐释了分支预测对代码处理速度的影响。

将此文翻译并分享下。


问: by GManNickG

以下是一段非常特殊的C++代码。因为某些原因,将数组排序使代码运行速度奇迹般得快了6倍:

<span style="font-size:14px;">#include <algorithm>
#include <ctime>
#include <iostream>

int main()
{
    // Generate data
    const unsigned arraySize = 32768;
    int data[arraySize];

    for (unsigned c = 0; c < arraySize; ++c)
        data[c] = std::rand() % 256;

    // !!! With this, the next loop runs faster
    std::sort(data, data + arraySize);

    // Test
    clock_t start = clock();
    long long sum = 0;

    for (unsigned i = 0; i < 100000; ++i)
    {
        // Primary loop
        for (unsigned c = 0; c < arraySize; ++c)
        {
            if (data[c] >= 128)
                sum += data[c];
        }
    }

    double elapsedTime = static_cast<double>(clock() - start) / CLOCKS_PER_SEC;

    std::cout << elapsedTime << std::endl;
    std::cout << "sum = " << sum << std::endl;
}
</span>


如没有 std::sort(data, data + arraySize);,代码运行需11.54秒。
如排列数据,代码运行只需1.93秒。


起初我认为这可能是语言或编译器异常。所以我编写了一段Java代码来尝试:

</pre><pre name="code" class="java"><span style="font-size:14px;">import java.util.Arrays;
import java.util.Random;

public class Main
{
    public static void main(String[] args)
    {
        // Generate data
        int arraySize = 32768;
        int data[] = new int[arraySize];

        Random rnd = new Random(0);
        for (int c = 0; c < arraySize; ++c)
            data[c] = rnd.nextInt() % 256;

        // !!! With this, the next loop runs faster
        Arrays.sort(data);

        // Test
        long start = System.nanoTime();
        long sum = 0;

        for (int i = 0; i < 100000; ++i)
        {
            // Primary loop
            for (int c = 0; c < arraySize; ++c)
            {
                if (data[c] >= 128)
                    sum += data[c];
            }
        }

        System.out.println((System.nanoTime() - start) / 1000000000.0);
        System.out.println("sum = " + sum);
    }
}
</span>


与之前的结果很相似,但并没有相差那么多。
我的第一个想法是分类将数据带入高速缓存,但我的第二个想法是我的第一个想法是多么愚蠢,因为数组刚刚生成。

这到底是怎么回事?
为什么排列数组比未排列数组要快?
代码归纳出了一些独立的项,而且其顺序应该不是那么重要。

答: by Mysticial 

你是分支预测失败的受害人。
什么是分支预测?
想象下铁路道岔:


照片由Mecanismo通过Wikimedia Commons提供。在CC-By-SA 3.0许可证下使用


为避免争论,假使现在是19世纪初-长距离或无线电通讯发明之前。

你是铁路道岔的操作员并且你听到火车正驶来。你并不知道它要开到哪去。
你让火车停下,询问列车长要去哪个方向。而后你将道岔设置到正确的方向。

火车非常重且惯性十分大。所以火车要花很长时间启动和减速。

还有更好的方法吗?你猜测火车去哪个方向!
如果你猜对了,火车继续行驶。
如果你猜错了,列车长会停下火车,再倒退回来,还会大声训斥你去扳道岔。然后火车重新启动,向另一个方向行驶。

如果你每次都猜对了,火车永远不用停下。
如果你经常猜错,那么火车将会花费大量的时间停车、后退、重新启动。

考虑一个if条件语句:在处理器层面上,它是一个分支指令:


假如你是处理器,并且你看到了一个分支。你不知道它要往哪个方向走。你会怎么办?你停止运行,一直等到上个指令完全完成为止。然后你按正确方向继续处理。
当代处理器更为复杂,且有很长的管线。所以他们花很长时间“热身”与“减速”。
还有更好的方法吗?你猜测分支去哪个方向!

如果你猜对了,将继续运行。
如果你猜错了,将清除管线,回滚至分支处。然后重新启动,按另一个方向执行。

如果每次都猜对了,运行永远都不用停止。
如果你经常猜错,你花费大量时间停止、回滚和重启。
这就是一个分支预测。我承认这不是个很好的比喻,因为火车可以挥舞旗子当做信号来指示方向。但是在电脑里,处理器直到最后一刻才会知道分支会走向哪个方向

所以怎样策略的猜测火车驶往哪个方向,最大限度的减少火车后退、驶往另一个方向的次数?你注意到过往的历史!如果火车99%的时间都驶向了左边的方向,那么你就猜左边。如果相反,你就猜右边。如果火车3次都驶向同一个方向,你就猜那个方向…

换句话说,你试着确定一个模式并遵循此模式。这基本上就是分支预测是怎么运行的。
大多数应用有很好的分支预测。现代分支预测准确率通常大于90%。但当面对一些无模式不可预测的分支时,分支预测事实上没有什么用处。

请参阅维基百科上“分支预测”的条目了解更多信息。

如上述所暗示的,其元凶就是if条件语句:

<span style="font-size:14px;">if (data[c] >= 128)
    sum += data[c];
</span>

需要注意的是数据是在0-255之间均匀分布的。当数据分类时,约前一半的迭代次数将不进入if条件语句。在那之后,他们将全部进入if条件语句。
这对于分支预测是十分友好的,因为分支连续数次按同一方向进行。甚至一个简单的饱和计数器也能正确的预测分支,除了改变方向后的几次迭代。

快速浏览:

<span style="font-size:14px;">T = branch taken
N = branch not taken

data[] = 0, 1, 2, 3, 4, ... 126, 127, 128, 129, 130, ... 250, 251, 252, ...
branch = N  N  N  N  N  ...   N    N    T    T    T  ...   T    T    T  ...

       = NNNNNNNNNNNN ... NNNNNNNTTTTTTTTT ... TTTTTTTTTT  (easy to predict)
</span>

不论怎样,当数据完全是随机的时候,分支预测是没有用处的,应为他不能预测随机数据。因此将有约50%的错误预测(与随机猜测几乎一样)。

<span style="font-size:14px;">data[] = 226, 185, 125, 158, 198, 144, 217, 79, 202, 118,  14, 150, 177, 182, 133, ...
branch =   T,   T,   N,   T,   T,   T,   T,  N,   T,   N,   N,   T,   T,   T,   N  ...

       = TTNTTTTNTNNTTTN ...   (completely random - hard to predict)
</span>

所以我们能做些什么?
如果编译器无法将分支优化进入条件移动指令,你可以尝试一些修改,前提是你愿意牺牲掉性能的可读性。
删除以下语句:

<span style="font-size:14px;">if (data[c] >= 128)
    sum += data[c];
</span>

用以下语句代替删掉的if条件语句:

<span style="font-size:14px;">int t = (data[c] - 128) >> 31;
sum += ~t & data[c];
</span>

这将消除分支,用逐位运算代替分支。

(注意这里的修改并不是严格等同于原始的if条件语句。但在此情况下,它对所有data[]的输入值都是有效的)

基准: Core i7 920 @ 3.5 GHz
C++ - Visual Studio 2010 - x64版

<span style="font-size:14px;">//  Branch - Random
seconds = 11.777

//  Branch - Sorted
seconds = 2.352

//  Branchless - Random
seconds = 2.564

//  Branchless - Sorted
seconds = 2.587
</span>

Java - Netbeans 7.1.1 JDK 7 - x64

<span style="font-size:14px;">//  Branch - Random
seconds = 10.93293813

//  Branch - Sorted
seconds = 5.643797077

//  Branchless - Random
seconds = 3.113581453

//  Branchless - Sorted
seconds = 3.186068823
</span>

观察:
带有分支:排列数据和未排列数据有很大差别 。
带有修改:排列数据和未排列数据并没有差别。
如为C++语言环境,当数据排序时,此修改实际上要比分支慢一点。


一条通用的经验法则就是在关键循环中避开依靠数据的分支(如此例)。
更新:
GCC 4.6.1 -O3版或x64上的-free-vectorize可生成条件移动指令。所以排列数据和未排列数据没有差别—都很快。 
VC++ 2010甚至不能在/Ox下为分支生成条件移动指令。
英特尔编译器11做了一些不可思议的事情。它互换了两个循环,从而将不可预知的分支升至外循环。所以不仅消除了错误预测,而且不论生成什么都比VC++和GCC快两倍!换句话说,ICC用测试循环击败了基准测试…
如果你给英特尔编译器一段无分支的代码,它便立刻使代码矢量化了…如有分支时(带有循环互换)一样快。
这展示了成熟的现代编译器可用其野蛮的变化代码并将其优化的能力……

点击原文链接

此文在CC-By-SA 3.0许可证下使用

抱歉!评论已关闭.