为什么处理一个已排序的数组比处理一个未排序的数组要快?

27157
在这段C++代码中,对数据进行排序(在定时区域之前)可以使主循环的速度提高约6倍。
#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';
}
  • 没有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);
    }
}

与类似但较不极端的结果。
我的第一个想法是排序会将数据放入缓存中,但这是愚蠢的,因为数组刚刚生成。
  • 到底发生了什么?
  • 为什么处理排序后的数组比处理未排序的数组快?

代码正在对一些独立的项求和,所以顺序不应该有影响。


相关/后续问答关于使用不同/较新编译器和选项产生相同效果的问题:


119
另一个观察结果是,您不需要对数组进行排序,只需要使用值128对其进行划分即可。排序的时间复杂度为n*log(n),而划分的时间复杂度仅为线性。基本上只需要运行快速排序划分步骤一次,选择值为128作为枢轴。不幸的是,在C ++中只有nth_element函数,它按位置进行划分,而不是按值进行划分。 - Šimon Hrabec
46
这是一个实验,可以证明分区已经足够:创建一个无序但已分区的数组,并填充随机内容。测量时间。对其进行排序。再次测量时间。这两个测量结果应该基本相同。(实验2:创建一个随机数组。测量时间。对其进行分区。再次测量时间。您应该会看到与排序相同的加速效果。您可以将这两个实验合并为一个。) - Jonas Kölker
41
顺便说一下,在苹果M1上,代码在未排序的情况下运行需要17秒,排序后只需要7秒,因此在RISC架构上,分支预测惩罚并不那么严重。 - Piotr Czapla
36
这取决于编译器。如果编译器为这个特定的测试生成无分支汇编代码(例如作为使用 SIMD 向量化的一部分,就像在为什么处理未排序的数组与处理已排序的数组在现代 x86-64 clang 中速度相同?中所述的那样,或者只是使用标量 cmovgcc 优化标志 -O3 使代码比 -O2 更慢)),那么有序或无序并不重要。但是当问题不像计数那样简单时,不可预测的分支仍然是一个非常真实的问题,因此删除这个问题是不明智的。 - Peter Cordes
20
公正地说,尽管如此,将其分区仍然不值得,因为分区需要根据相同的array[i]>128比较进行条件复制或交换。(除非您要多次计数,并且希望将数组的大部分分区以使其仍然快速,在一些附加或修改后未分区的部分中出现错误预测)。如果您可以让编译器执行此操作,最好使用SIMD进行向量化,或者至少在数据不可预测时使用无分支标量。(请参见上面的评论获取链接。) - Peter Cordes
显示剩余5条评论
26个回答

209

排序数组的处理速度比未排序数组快,这是因为存在一种被称为分支预测现象的东西。

分支预测器是一种数字电路(在计算机架构中),试图预测分支走向,以改善指令流水线的流程。该电路/计算机预测下一步并执行它。

犯了错误的预测会导致返回到上一步,然后使用另一种预测进行执行。假设预测正确,代码将继续执行下一步。错误的预测会导致重复相同的步骤,直到发生正确的预测。

回答你的问题非常简单。

在未排序的数组中,计算机进行多次预测,导致错误的几率增加。 而在排序的数组中,计算机进行较少的预测,从而降低了错误的几率。 进行更多的预测需要更多的时间。

排序数组:直路

____________________________________________________________________________________
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
TTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTT

未排序的数组:弯曲之路

______   ________
|     |__|

分支预测:猜测/预测哪条路是直的并在没有检查的情况下跟随它

___________________________________________ Straight road
 |_________________________________________|Longer road

虽然两条路都通往同样的目的地,但直路更短,另一条路更长。如果你错选了那条路,就无法回头,所以如果选择了较长的那条路,会浪费一些额外的时间。这类似于计算机中发生的情况,我希望这可以帮助你更好地理解。


此外,我要引用评论区的@Simon_Weaver 的话:

它不是做出更少的预测 - 它只是做出更少的错误预测。它仍然必须为每次循环预测...


186

我使用我的MacBook Pro (Intel i7, 64 bit, 2.4 GHz)尝试了同样的代码,对于以下MATLAB代码:

% Processing time with Sorted data vs unsorted data
%==========================================================================
% Generate data
arraySize = 32768
sum = 0;
% Generate random integer data from range 0 to 255
data = randi(256, arraySize, 1);


%Sort the data
data1= sort(data); % data1= data  when no sorting done


%Start a stopwatch timer to measure the execution time
tic;

for i=1:100000

    for j=1:arraySize

        if data1(j)>=128
            sum=sum + data1(j);
        end
    end
end

toc;

ExeTimeWithSorting = toc - tic;

以上 MATLAB 代码的结果如下:

  a: Elapsed time (without sorting) = 3479.880861 seconds.
  b: Elapsed time (with sorting ) = 2377.873098 seconds.

按照@GManNickG的C代码,我得到了以下结果:

  a: Elapsed time (without sorting) = 19.8761 sec.
  b: Elapsed time (with sorting ) = 7.37778 sec.

基于此,看起来MATLAB实现的速度几乎比C实现慢175倍(未排序)和350倍(排序后)。换句话说,分支预测对MATLAB实现的影响为1.46倍,对C实现的影响为2.7倍


9
为了完整起见,这可能不是您在Matlab中实现该功能的方式。我敢打赌,如果将问题向量化后再处理,速度会更快。 - ysap
2
Matlab在许多情况下都可以进行自动并行化/向量化,但问题在于要检查分支预测的影响。Matlab并不以任何方式免疫! - Shan
2
Matlab使用本地数字还是Matlab特定的实现(无限数量的数字或类似)? - Thorbjørn Ravn Andersen

119

其他答案认为需要对数据进行排序的假设是不正确的。

下面的代码并没有对整个数组进行排序,而只是对它的200个元素段进行排序,从而使代码运行得最快。

仅对k个元素段进行排序可在线性时间 O(n) 完成预处理,而不需要对整个数组排序所需的 O(n.log(n)) 时间。

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

int main() {
    int data[32768]; const int l = sizeof data / sizeof data[0];

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

    // sort 200-element segments, not the whole array
    for (unsigned c = 0; c + 200 <= l; c += 200)
        std::sort(&data[c], &data[c + 200]);

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

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

    std::cout << static_cast<double>(clock() - start) / CLOCKS_PER_SEC << std::endl;
    std::cout << "sum = " << sum << std::endl;
}

这还"证明"了它与任何算法问题(如排序顺序)无关,确实是分支预测。


8
我并不真的看得出这证明了什么?你所展示的唯一事实就是“没有对整个数组进行排序,花费的时间比对整个数组进行排序要少”。你声称这个方法“也是最快的”,但这其实与计算机架构有很大关系。请参考我的回答,了解在ARM上如何实现。顺便说一句,你可以在非ARM架构上通过将求和放在200元素块循环内部、倒序排序,然后使用Yochai Timmer的建议,在获取超出范围的值后中断程序来使代码更快。这样每个200元素块的求和都可以提前终止。 - Luke Hutchison
1
如果您只想在未排序的数据上高效实现算法,那么您将无需分支地执行该操作(并使用SIMD,例如使用x86 pcmpgtb 查找其高位设置的元素,然后使用AND将较小的元素归零)。花费任何时间来实际排序块都会更慢。无分支版本将具有数据独立性能,也证明了成本来自于分支错误预测。或者直接使用性能计数器观察,例如Skylake int_misc.clear_resteer_cyclesint_misc.recovery_cycles 来计算由于错误预测而导致的前端空闲周期。 - Peter Cordes
2
以上两个评论似乎都忽略了一般算法问题和复杂性,而倾向于提倡具有特殊机器指令的专用硬件。我认为第一个评论特别琐碎,因为它轻率地忽视了这个答案中重要的一般见解,盲目支持专门的机器指令。 - user2297550
1
还要注意的是,如果if语句内部的计算比简单加法复杂得多,在一般情况下这种专门的硬件指令是无助于提高性能的。因此,本答案独具特色,提供了仍然具有 O(n) 的通用解决方案。 - user2297550
1
仅供记录,由于我们之前的评论已经被删除,我认为花时间(部分)排序在整体性能上并没有什么收益,除非您像在这个微基准测试中一样人为地重复数组上的循环。然后是的,这个分段排序接近于完全排序的好处(例如,在Skylake上,这个排序需要2.4秒,而完全排序需要1.7秒,而没有排序则需要10.9秒才能完成100k次传递。如果您使用g++-Os-Wa,-mbranches-within-32B-boundaries来产生带有分支的汇编代码而不是普通的构建)。 - Peter Cordes
显示剩余4条评论

110

Bjarne Stroustrup的回答:

这听起来像是一个面试题,对吗?你怎么知道呢?在回答有关效率的问题之前,最好先进行一些测量,因此了解如何进行测量非常重要。

因此,我使用了一百万个整数的向量,并得到了以下结果:

Already sorted    32995 milliseconds
Shuffled          125944 milliseconds

Already sorted    18610 milliseconds
Shuffled          133304 milliseconds

Already sorted    17942 milliseconds
Shuffled          107858 milliseconds

我跑了几次以确保。是的,这种现象是真实的。我的密钥代码是:

void run(vector<int>& v, const string& label)
{
    auto t0 = system_clock::now();
    sort(v.begin(), v.end());
    auto t1 = system_clock::now();
    cout << label
         << duration_cast<microseconds>(t1 — t0).count()
         << " milliseconds\n";
}

void tst()
{
    vector<int> v(1'000'000);
    iota(v.begin(), v.end(), 0);
    run(v, "already sorted ");
    std::shuffle(v.begin(), v.end(), std::mt19937{ std::random_device{}() });
    run(v, "shuffled    ");
}

至少对于这个编译器、标准库和优化器设置,这种现象是真实的。不同的实现可能会得出不同的答案。事实上,有人进行了更系统的研究(快速的网络搜索可以找到),大多数实现都展现了这种效应。

其中一个原因是分支预测:排序算法中的关键操作是 “if(v[i] < pivot]) …” 或等价操作。对于已排序的序列,该测试始终为真,而对于随机序列,所选择的分支是随机变化的。

另一个原因是当向量已经排序时,我们永远不需要移动元素到正确的位置。这些小细节的效果是我们看到的五到六倍差距。

快速排序(以及排序)是一个复杂的领域,吸引了计算机科学中一些最伟大的思想家。一种好的排序函数是选择一个好的算法并在其实现中注意硬件性能的结果。

如果你想写出高效的代码,你需要了解一些机器结构知识。


1
这似乎没有理解问题的重点,而是回答了排序本身在已排序数组中是否更快的问题。这并不令人惊讶,因为正如这个答案指出的那样,在大多数排序算法(除了归并排序)中要做的工作较少,再加上分支预测效应。实际的问题将这种效应剔除,并且只计时条件递增。 - Peter Cordes

101

4
指令在 CPU 的 L1 指令缓存中保持热度,无论是否出现错误预测。问题在于在立即前面的指令解码并完成执行之前,以正确的顺序将它们提取到 流水线 中。 - Peter Cordes
2
此外,在一个简单的CPU中,如果有“指令寄存器”,那么在执行每个指令时,它肯定需要将每个指令读入IR中。这个答案的最后一段与CPU的实际工作方式非常扭曲。一些带有循环缓冲区的CPU可以将一系列指令锁定到一个循环中,以避免甚至重新从L1i缓存中获取,只要它们继续以相同的方式执行即可,但这通常是次要的(例如,在Intel Skylake中,禁用LSD的微码更新并没有对其造成太大影响),只是从正确的分支预测中获得了更多的价值。 - Peter Cordes
1
这篇论文大致介绍了如何从o(n)的角度处理协调数据作为指令的获取方式,同时它是在90年代早期编写的,因此当时不存在任何尖端的内存/寄存器设计。现代CPU缓存设计和算法可以在多篇基准论文中找到,其中之一可能是https://ieeexplore.ieee.org/document/1027060?arnumber=1027060。 - hatirlatici
1
我不是在谈论链接的论文内容,而是在谈论你实际回答中的句子,特别是最后一段。 (你回答中提到的论文发表于1993年,提到了超标量CPU和CPU架构的未来方向,因此乱序执行已经在视野内,并且它肯定假设了多个指令的并行获取和解码。事实上,这就是他们提议的整个重点;在更宽的设计中每个时钟周期看穿多个分支,将它们从L1i缓存中获取到管道中。当前的CPU仍然没有做到这一点。) - Peter Cordes

29

一个简单易懂的答案(如果需要更多细节请阅读其他内容)

这个概念被称为分支预测

分支预测是一种优化技术,可以在代码执行前预测代码路径。这很重要,因为在代码执行期间,机器会预取多个代码语句并将它们存储在流水线中。

问题出现在条件分支中,其中有两个可能的路径或代码部分可以被执行。

当预测正确时,优化技术便可以奏效。

当预测失败时,简单地说,存储在管道中的代码语句就会被证明错误,实际代码必须完全重新加载,这需要耗费大量时间。

常识告诉我们,对已排序的数据进行预测比对未排序的数据进行预测更准确。

分支预测可视化:

已排序
sorted 未排序 unsorted


5
在排序后的火车轨道/执行路径中间应该有一个变化,因为循环内部的分支在前一半被执行,后一半不被执行(或者反过来)。此外,在未排序的情况下,5个不同级别代表什么意思?这是一个两路分支。 - Peter Cordes

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接