为什么处理一个排序的数组比处理一个未排序的数组快? | Java-Debug实录

提问:为什么处理一个排序的数组比处理一个未排序的数组快?

这里有一段C++代码,显示很奇怪。异常现象为:对数据进行排序竟奇迹般地使代码的速度提高了六倍左右。

#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)
    {
        for (unsigned c = 0; c < arraySize; ++c)
        {   // Primary loop
            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;
}
复制代码

如果没有std::sort(data, data + arraySize);,代码运行时间为11.54秒。
有了排序后的数据,该代码的运行时间为1.93秒。

起初,我认为这可能是语言或编译器的异常,所以我试了一下Java

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)
        {
            for (int c = 0; c < arraySize; ++c)
            {   // Primary loop
                if (data[c] >= 128)
                    sum += data[c];
            }
        }

        System.out.println((System.nanoTime() - start) / 1000000000.0);
        System.out.println("sum = " + sum);
    }
}
复制代码

有一个类似但差强人意的结果

我的第一个想法是,排序将数据带入缓存,但后来我想这是多么愚蠢,因为数组刚刚生成。

到底是怎么回事呢?
为什么处理一个排序的数组比处理一个未排序的数组要快?
代码是对一些独立的项进行求和,所以顺序应该不重要。

最热门回答:

你是分支预测失败的受害者。

什么是分支预测?
考虑一个铁路枢纽。

Image showing a railroad junction

现在为了争论,假设在19世纪–在长距离或无线电通信之前。

你是一个路口的操作员,你听到火车来了,不知道它应该往哪边走;你停下火车,问司机他们想要哪个方向,然后你适当地设置开关。

火车很重,有很大的惯性。因此,他们需要花很长时间来启动和放慢。

有更好的方法吗?你猜一猜火车会去哪个方向!

如果你猜对了,它就继续前进。
如果你猜错了,车长就会停下来,往后退,并喊你去翻开开关。然后它就可以重新开始走另一条路了。
如果你每次都猜对了,火车就永远不用停下来。
如果你经常猜错,火车就会花很多时间停下来,倒车,再重新启动。

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

包含if语句的编译代码的截图

你是一个处理器,你看到一个分支。你不知道它将走哪条路。你会怎么做?你停止执行并等待,直到前面的指令完成。然后你继续沿着正确的路径前进。

现代处理器很复杂,有很长的流水线。所以它们需要永远 “热身 “和 “减速”。

有更好的方法吗?你猜猜看分支会往哪个方向走!

如果你猜对了,你就继续执行。
如果你猜错了,你需要冲刷管道并回滚到分支上。然后你就可以沿着另一条路径重新开始。
如果你每次都猜对了,那么执行就永远不必停止。
如果你经常猜错,你就会花很多时间来停顿、回滚和重启。

这就是分支预测。我承认这不是最好的比喻,因为火车可以直接用旗子示意方向。但在计算机中,处理器直到最后一刻才知道一个分支会去哪个方向。

那么,你如何从战略上猜测,以尽量减少火车必须倒车和走另一条路的次数?你看一下过去的历史! 如果火车99%的时间都是向左行驶,那么你就猜测向左。如果它交替出现,那么你就交替猜测。如果它每三次都走一条路,你就猜一样的……

换句话说,你试图找出一种模式并持续去遵循它。这或多或少是分支预测器的工作方式。

大多数应用程序都有行为良好的分支。因此,现代分支预测器通常会达到90%以上的命中率。但是,当面对没有可识别模式的不可预测的分支时,分支预测器几乎毫无用处。

进一步阅读。维基百科上的 “分支预测器 “文章。

正如上文所暗示的,罪魁祸首是这个if-statement。

if (data[c] >= 128)
    sum += data[c];
复制代码

请注意,数据均匀地分布在0和255之间。当数据被排序时,大约前一半的迭代将不进入if语句。之后,它们将全部进入if语句。

这对分支预测器是非常友好的,因为分支会连续多次向同一方向发展。即使是一个简单的饱和计数器也能正确预测分支,除了它转换方向后的几个迭代。

显而易见:

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)
复制代码

然而,当数据完全随机时,分支预测器就变得毫无用处了,因为它无法预测随机数据。因此可能会有大约50%的预测错误(不比随机猜测好)。

data[] = 226, 185, 125, 158, 198, 144, 217, 79, 202, 118,  14, 150, 177, 182, ...
branch =   T,   T,   N,   T,   T,   T,   T,  N,   T,   N,   N,   T,   T,   T  ...

       = TTNTTTTNTNNTTT ...   (completely random - impossible to predict)
复制代码
那么,我们可以做什么呢?

如果编译器不能将分支优化为条件移动,如果你愿意为性能牺牲可读性,你可以尝试一些小技巧。

替换

if (data[c] >= 128)
    sum += data[c];
复制代码

int t = (data[c] - 128) >> 31;
sum += ~t & data[c];
复制代码

这就取消了分支,而用一些位操作来代替它。

(注意,这个黑客并不严格等同于原始的if语句。但在这种情况下,它对data[]的所有输入值都有效)。)

基准测试 Core i7 920 @ 3.5 GHz

C++ – Visual Studio 2010 – x64 版本

Scenario Time (seconds)
Branching – Random data 11.777
Branching – Sorted data 2.352
Branchless – Random data 2.564
Branchless – Sorted data 2.587

Java – NetBeans 7.1.1 JDK 7 – x64

Scenario Time (seconds)
Branching – Random data 10.93293813
Branching – Sorted data 5.643797077
Branchless – Random data 3.113581453
Branchless – Sorted data 3.186068823

可以观察到

随着分支的出现,排序后的数据和未排序的数据之间存在巨大差异
使用Hack:排序后的数据和未排序的数据之间没有区别
在C++情况下,当数据被排序时,Hack实际上比使用分支的速度要慢一点
一般的经验法则是避免在关键循环中进行数据依赖性的分支(比如在这个例子中)。

更新

在x64上使用-O3或-ftree-vectorize的GCC 4.6.1能够生成一个条件移动;因此,排序后的数据和未排序的数据之间没有区别–两者都很快。

(或者说有点快:对于已经排序的情况,cmov可能更慢,特别是如果GCC把它放在关键路径上,而不是仅仅添加,特别是在Broadwell之前的Intel上,cmov有2个周期的延迟:GCC优化标志-O3使代码比-O2更慢)

即使在/Ox下,VC++ 2010也无法为这个分支生成条件性动作。

英特尔C++编译器(ICC)11做了一些神奇的事情。它将两个循环互换,从而将不可预测的分支吊在外循环上。因此,它不仅对错误预测有免疫力,而且比VC++和GCC所能产生的速度快了一倍!也就是说,ICC利用了错误预测。换句话说,ICC利用了测试循环的优势来打败了基准…

如果你把无分支的代码交给英特尔编译器,它就直接把它矢量化了……而且和有分支的时候一样快(有循环互换)。

这表明,即使是成熟的现代编译器在优化代码的能力上也会有很大的不同……

文章翻译自Stack Overflow :stackoverflow.com/questions/1…

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享