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

发布时间 2023-10-06 12:30:11作者: 小满独家

在这个C++代码中,在计时区域之前对数据进行排序(*)使得主循环快6倍:

#include <algorithm>
#include <ctime>
#include <iostream>

int main()
{
    // 生成数据
    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);

    // 测试
    clock_t start = clock();
    long long sum = 0;
    for (unsigned i = 0; i < 100000; ++i)
    {
        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 << '\n';
    std::cout << "sum = " << sum << '\n';
}
  • 如果不使用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)
    {
        // 生成数据
        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);

        // 测试
        long start = System.nanoTime();
        long sum = 0;
        for (int i = 0; i < 100000; ++i)
        {
            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);
    }
}

结果类似但程度略低。


我的第一反应是排序将数据带入缓存(https://en.wikipedia.org/wiki/CPU_cache ),但这很愚蠢,因为数组刚刚生成。

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

代码正在对一些独立的项求和,因此顺序不应该很重要。


你成为了分支预测失败的受害者。

什么是分支预测?

想象一个铁路交叉口:

显示铁路交叉口的图片
图片 by Mecanismo,通过Wikimedia Commons获得。根据CC-By-SA 3.0许可证使用。

现在为了论证,假设这是回到19世纪的事情 - 在远距离或无线电通信之前。

你是交叉口的一个盲目操作员,你听到一列火车驶来。你不知道它应该往哪个方向去。你停下来让司机告诉你他们想要的方向。然后你适当地设置开关。

火车很重且具有很大的惯性,所以它们需要很长时间才能启动和减速。

有更好的方法吗?你可以猜测火车将往哪个方向行驶!

*如果你猜对了,它将继续前进。
*如果你猜错了,船长将停下来,后退,并大声责骂你要翻转开关。然后它可以重新启动沿着另一条路径。

如果你每次都猜对,火车将永远不会停下来。

如果你猜错的次数太多,火车将花费很多时间停下来,后退,然后重新启动。


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

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

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

现代处理器复杂且具有长流水线。这意味着它们需要很长时间才能“预热”和“减速”。

有更好的方法吗?你可以猜测分支将往哪个方向行驶!

*如果你猜对了,你将继续执行。
*如果你猜错了,你需要冲洗流水线并回滚到分支。然后你可以重新启动沿着另一条路径。

如果你每次都猜对,执行将永远不会停止。

如果你猜错的次数太多,你会花费很多时间停止、回滚和重新启动。


这就是分支预测。我承认这个比喻并不完美,因为火车可以使用标志来指示方向。但在计算机中,处理器在分支的最后一刻才知道分支将走向何方。

你会如何战略性地猜测以最小化火车必须后退并沿着另一条路径重新启动的次数?你看过去的历史!如果火车99%的时间都向左,那么你猜测左边。如果它交替,那么你交替你的猜测。如果它每三次向一个方向前进一次,你猜测相同的...

换句话说,你尝试识别一个模式并遵循它。这大致是分支预测器的工作方式。

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

进一步阅读:【分支预测器】维基百科文章。
这是非常友好的分支预测器,因为分支连续地走相同的方向很多次。即使是一个简单的饱和计数器也可以正确预测分支,除了在它切换方向后的几次迭代之外。

快速可视化:

T = 分支被采取
N = 分支未被采取

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

       = NNNNNNNNNNNN ... NNNNNNNTTTTTTTTT ... TTTTTTTTTT  (容易预测)

然而,当数据完全随机时,分支预测器将被证明是无用的,因为它无法预测随机数据。因此,可能会有大约50%的误预测(不比随机猜测好)。

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

       = TTNTTTTNTNNTTT ...   (完全随机 - 无法预测)


可以做什么?

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

将以下代码:

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 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++情况下,当数据已排序时,技巧实际上比有分支的情况稍慢。

一般规则是,在关键循环中避免数据相关的分支(例如本例)。