为什么处理已排序的数组比处理未排序的数组更快?

下面是一段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 << '\n';
    std::cout << "sum = " << sum << '\n';
}
  • 如果没有,代码将在 11.54 秒内运行。std::sort(data, data + arraySize);
  • 使用排序后的数据,代码将在 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);
    }
}

具有类似但不那么极端的结果。


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

  • 这是怎么回事?
  • 为什么处理已排序的数组比处理未排序的数组更快?

代码总结了一些独立的术语,因此顺序无关紧要。


相关/后续问答,关于不同/更高版本的编译器和选项的相同效果:


答案 1

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


什么是分支预测?

考虑一个铁路交汇点:

Image showing a railroad junction 图片由Mecanismo提供,通过维基共享资源。在 CC-By-SA 3.0 许可证下使用。

现在为了论证,假设这是在1800年代 - 在远距离或无线电通信之前。

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

火车很重,有很多惯性,所以它们需要永远启动和减速。

有没有更好的方法?你猜火车会往哪个方向走!

  • 如果你猜对了,它会继续下去。
  • 如果你猜错了,船长会停下来,后退,对你大喊大叫,让你扳动开关。然后,它可以沿着另一条路径重新启动。

如果你每次都猜对了,火车将永远不必停下来。
如果你猜错了,火车会花很多时间停下来,倒车,然后重新启动。


考虑一个 if 语句:在处理器级别,它是一个分支指令:

Screenshot of compiled code containing an if statement

你是一个处理器,你看到一个分支。你不知道它会走哪条路。你是做什么工作的?暂停执行并等待前面的指令完成。然后你继续沿着正确的道路走下去。

现代处理器很复杂,并且具有很长的管道。这意味着他们需要永远“热身”和“放慢速度”。

有没有更好的方法?你猜猜分公司会朝哪个方向走!

  • 如果你猜对了,你继续执行。
  • 如果猜错了,则需要刷新管道并回滚到分支。然后,您可以沿着另一条路径重新启动。

如果你每次都猜对了,执行将永远不必停止。
如果你猜错了,你会花很多时间停滞、回滚和重新启动。


这是分支预测。我承认这不是最好的类比,因为火车可以用一面旗帜来指示方向。但在计算机中,处理器直到最后一刻才知道分支会朝哪个方向发展。

您如何从战略上猜测,以尽量减少火车必须倒车并沿着另一条路径行驶的次数?你看看过去的历史!如果火车99%的时间都是左转的,那么你猜是左转的。如果它交替,那么你交替你的猜测。如果它每三次走一条路,你猜是一样的......

换句话说,你试图识别一个模式并遵循它。这或多或少是分支预测变量的工作方式。

大多数应用程序都有表现良好的分支。因此,现代分支预测器通常可实现>90% 的命中率。但是,当面对没有可识别模式的不可预测的分支时,分支预测器实际上是无用的。

延伸閱讀:維基百科上的“分支預測器”文章


正如上面所暗示的,罪魁祸首是这个if语句:

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];

这将消除分支并将其替换为一些按位操作。

(请注意,此 hack 并不严格等同于原始的 if 语句。但在本例中,它对数据的所有输入值都有效[]

基准测试: 酷睿 i7 920 @ 3.5 GHz

C++ - Visual Studio 2010 - x64 Release

场景 时间(秒)
分支 - 随机数据 11.777
分支 - 排序数据 2.352
无分支 - 随机数据 2.564
无分支 - 已排序的数据 2.587

Java - NetBeans 7.1.1 JDK 7 - x64

场景 时间(秒)
分支 - 随机数据 10.93293813
分支 - 排序数据 5.643797077
无分支 - 随机数据 3.113581453
无分支 - 已排序的数据 3.186068823

观察:

  • 与分行:已排序和未排序的数据之间存在巨大差异。
  • 使用黑客:已排序和未排序数据之间没有区别。
  • 在C++的情况下,在对数据进行排序时,黑客攻击实际上比分支慢一点。

一般的经验法则是避免在关键循环中进行依赖于数据的分支(如本例所示)。


更新:

  • 带有或基于x64的GCC 4.6.1能够生成条件移动,因此排序和未排序数据之间没有区别 - 两者都很快。-O3-ftree-vectorize

    (或者有点快:对于已经排序的情况,可能会更慢,特别是如果GCC将其放在关键路径上,而不仅仅是,特别是在Broadwell之前的英特尔,那里有2个周期延迟:gcc优化标志-O3使代码比-O2慢cmovaddcmov)

  • VC++ 2010 无法为此分支生成条件移动,即使在 ./Ox

  • 英特尔C++编译器 (ICC) 11 做到了奇迹。它互换两个环路,从而将不可预测的分支提升到外环路。它不仅不受错误预测的影响,而且速度也是VC ++和GCC可以生成的两倍!换句话说,ICC利用测试循环击败了基准测试......

  • 如果您为英特尔编译器提供无分支代码,它就会直接对其进行矢量化...并且与分支一样快(使用循环交换)。

这表明,即使是成熟的现代编译器,在优化代码的能力方面也可能有很大的不同。


答案 2

分支预测。

对于排序数组,条件首先是一连串的值,然后是所有后面的值。这很容易预测。使用未排序的数组,您需要支付分支成本。data[c] >= 128falsetrue


推荐