在冒泡排序中,使用">"和">="会导致显著的性能差异。

77

我刚刚偶然发现了些事情。起初,我以为这可能是一个分支误预测的案例,就像这个案例一样,但是我不能解释为什么分支误预测会导致这种行为。

我在Java中实现了两个版本的Bubble Sort,并进行了一些性能测试:

import java.util.Random;

public class BubbleSortAnnomaly {

    public static void main(String... args) {
        final int ARRAY_SIZE = Integer.parseInt(args[0]);
        final int LIMIT = Integer.parseInt(args[1]);
        final int RUNS = Integer.parseInt(args[2]);

        int[] a = new int[ARRAY_SIZE];
        int[] b = new int[ARRAY_SIZE];
        Random r = new Random();
        for (int run = 0; RUNS > run; ++run) {
            for (int i = 0; i < ARRAY_SIZE; i++) {
                a[i] = r.nextInt(LIMIT);
                b[i] = a[i];
            }

            System.out.print("Sorting with sortA: ");
            long start = System.nanoTime();
            int swaps = bubbleSortA(a);

            System.out.println(  (System.nanoTime() - start) + " ns. "
                               + "It used " + swaps + " swaps.");

            System.out.print("Sorting with sortB: ");
            start = System.nanoTime();
            swaps = bubbleSortB(b);

            System.out.println(  (System.nanoTime() - start) + " ns. "
                               + "It used " + swaps + " swaps.");
        }
    }

    public static int bubbleSortA(int[] a) {
        int counter = 0;
        for (int i = a.length - 1; i >= 0; --i) {
            for (int j = 0; j < i; ++j) {
                if (a[j] > a[j + 1]) {
                    swap(a, j, j + 1);
                    ++counter;
                }
            }
        }
        return (counter);
    }

    public static int bubbleSortB(int[] a) {
        int counter = 0;
        for (int i = a.length - 1; i >= 0; --i) {
            for (int j = 0; j < i; ++j) {
                if (a[j] >= a[j + 1]) {
                    swap(a, j, j + 1);
                    ++counter;
                }
            }
        }
        return (counter);
    }

    private static void swap(int[] a, int j, int i) {
        int h = a[i];
        a[i] = a[j];
        a[j] = h;
    }
}

正如我们所看到的,这两种排序方法之间唯一的区别在于>>=。当使用java BubbleSortAnnomaly 50000 10 10运行程序时,人们显然会预期sortBsortA慢,因为它需要执行更多的swap(...)。但是我在三台不同的机器上得到了以下(或类似的)输出:

Sorting with sortA: 4.214 seconds. It used  564960211 swaps.
Sorting with sortB: 2.278 seconds. It used 1249750569 swaps.
Sorting with sortA: 4.199 seconds. It used  563355818 swaps.
Sorting with sortB: 2.254 seconds. It used 1249750348 swaps.
Sorting with sortA: 4.189 seconds. It used  560825110 swaps.
Sorting with sortB: 2.264 seconds. It used 1249749572 swaps.
Sorting with sortA: 4.17  seconds. It used  561924561 swaps.
Sorting with sortB: 2.256 seconds. It used 1249749766 swaps.
Sorting with sortA: 4.198 seconds. It used  562613693 swaps.
Sorting with sortB: 2.266 seconds. It used 1249749880 swaps.
Sorting with sortA: 4.19  seconds. It used  561658723 swaps.
Sorting with sortB: 2.281 seconds. It used 1249751070 swaps.
Sorting with sortA: 4.193 seconds. It used  564986461 swaps.
Sorting with sortB: 2.266 seconds. It used 1249749681 swaps.
Sorting with sortA: 4.203 seconds. It used  562526980 swaps.
Sorting with sortB: 2.27  seconds. It used 1249749609 swaps.
Sorting with sortA: 4.176 seconds. It used  561070571 swaps.
Sorting with sortB: 2.241 seconds. It used 1249749831 swaps.
Sorting with sortA: 4.191 seconds. It used  559883210 swaps.
Sorting with sortB: 2.257 seconds. It used 1249749371 swaps.

当我把参数LIMIT设置为,例如,50000java BubbleSortAnnomaly 50000 50000 10),我得到了预期的结果:

Sorting with sortA: 3.983 seconds. It used  625941897 swaps.
Sorting with sortB: 4.658 seconds. It used  789391382 swaps.

我将程序移植到C++上,以确定这个问题是否只存在于Java中。以下是C++代码。

#include <cstdlib>
#include <iostream>

#include <omp.h>

#ifndef ARRAY_SIZE
#define ARRAY_SIZE 50000
#endif

#ifndef LIMIT
#define LIMIT 10
#endif

#ifndef RUNS
#define RUNS 10
#endif

void swap(int * a, int i, int j)
{
    int h = a[i];
    a[i] = a[j];
    a[j] = h;
}

int bubbleSortA(int * a)
{
    const int LAST = ARRAY_SIZE - 1;
    int counter = 0;
    for (int i = LAST; 0 < i; --i)
    {
        for (int j = 0; j < i; ++j)
        {
            int next = j + 1;
            if (a[j] > a[next])
            {
                swap(a, j, next);
                ++counter;
            }
        }
    }
    return (counter);
}

int bubbleSortB(int * a)
{
    const int LAST = ARRAY_SIZE - 1;
    int counter = 0;
    for (int i = LAST; 0 < i; --i)
    {
        for (int j = 0; j < i; ++j)
        {
            int next = j + 1;
            if (a[j] >= a[next])
            {
                swap(a, j, next);
                ++counter;
            }
        }
    }
    return (counter);
}

int main()
{
    int * a = (int *) malloc(ARRAY_SIZE * sizeof(int));
    int * b = (int *) malloc(ARRAY_SIZE * sizeof(int));

    for (int run = 0; RUNS > run; ++run)
    {
        for (int idx = 0; ARRAY_SIZE > idx; ++idx)
        {
            a[idx] = std::rand() % LIMIT;
            b[idx] = a[idx];
        }

        std::cout << "Sorting with sortA: ";
        double start = omp_get_wtime();
        int swaps = bubbleSortA(a);

        std::cout << (omp_get_wtime() - start) << " seconds. It used " << swaps
                  << " swaps." << std::endl;

        std::cout << "Sorting with sortB: ";
        start = omp_get_wtime();
        swaps = bubbleSortB(b);

        std::cout << (omp_get_wtime() - start) << " seconds. It used " << swaps
                  << " swaps." << std::endl;
    }

    free(a);
    free(b);

    return (0);
}

这个程序表现出相同的行为。有人可以解释一下到底发生了什么吗?

先执行 sortB,然后再执行 sortA 并不会改变结果。


1
你是如何计算时间的?如果你只针对一个案例计时,那么时间将会受到随机序列的影响,而 >>= 的影响将会非常小。为了得到真正有意义的时间数字,你需要测量许多不同的序列并取平均值。 - 463035818_is_not_a_number
@tobi303 看看这段代码。你可以通过第三个运行时参数(Java)或 -DRUNS=XXX(C++,编译器指令)在循环中运行它。而且结果是可重复的。 - Turing85
很有趣的是,统计两种情况下交换次数的数量可以看出它与运行时间的关系。我的意思是,在A情况下速度较慢,绝对不是因为交换次数的数量,所以也许在A情况下速度更快的原因也不仅仅是交换次数,而是一些更微妙的影响。 - 463035818_is_not_a_number
@Turing85:但是你重新运行了测试吗? - user2357112
当调用bubbleSortB()然后再调用bubbleSortA()时,看结果是否仍然成立会很有趣。在Java中,我经常怀疑内存分配和垃圾回收会导致意外的结果。尽管在C++中获得相同的结果表明这里发生了更普遍的事情。 - Kevin Condon
显示剩余5条评论
4个回答

45

我认为这可能确实是由于分支预测。如果你比较交换次数和内部排序迭代次数,你会发现:

Limit = 10

  • A = 560M 次交换 / 1250M 次循环
  • B = 1250M 次交换 / 1250M 次循环(交换次数比循环次数少了0.02%)

Limit = 50000

  • A = 627M 次交换 / 1250M 次循环
  • B = 850M 次交换 / 1250M 次循环

所以在 Limit == 10 的情况下,交换操作在 B 排序中执行的次数占到了99.98%,这显然对分支预测有利。而在 Limit == 50000 的情况下,交换操作只有68%的随机命中率,因此分支预测的作用更小。


2
你的论点看起来很有道理。有没有办法测试你的假设? - Turing85
1
快速的答案是控制输入数组,使得A/B的排序在同样的顺序中执行相同的交换(至少大致如此)。具体如何操作我不确定。您也可以查看交换顺序的随机性“某种程度上”是否与排序时间相关联。 - uesp
1
对于 LIMIT >= ARRAY_SIZE 的情况,你可以使用由唯一数组成的测试用例。例如,在 a[i] = ARRAY_SIZE - i 的情况下,每次循环都会发生交换,并且 A/B 排序的时间相同。 - uesp
@Turing85,注意我的回答确实解释了为什么会有这个交换次数的差异。 - Petr
@Petr,为什么有更多的交换是很明显的,但我只是无法将这个事实与分支误判相关联。而且我认为所选择的答案给出了最好的解释和最好的论证。 - Turing85
Java和C++的实现行为相似也是分支预测根源的强有力指标,因为芯片是相同的。 - Rune FS

11

我认为这可以通过分支预测错误来解释。

例如,考虑LIMIT=11和sortB。在外部循环的第一次迭代中,它将非常快地遇到等于10的元素之一。因此,它将有a[j]=10,因此a[j]肯定会>=a[next],因为没有大于10的元素。因此,它将执行一次交换,然后仅在j中进行一步操作,只需发现a[j]=10(相同的已交换值)。所以再一次,它将是a[j]>=a[next],以此类推。除了一开始的几个比较之外,每个比较都是真的。同样,它也会在外部循环的下一次迭代中运行。

sortA并非如此。它将以大致相同的方式开始,遇到a[j]=10,以类似的方式进行一些交换,但仅到找到a[next]=10为止。然后条件将为false,并且不会进行任何交换。以此类推:每次它遇到a[next]=10时,条件都为false,不进行任何交换。因此,该条件在11次中有10次为真(a[next]的值从0到9),在1次中为假。预测错误也就不奇怪了。


9
使用提供的C++代码(去除时间计数)并配合perf stat命令,我得到了结果,证实了分支失效理论。
Limit = 10时,BubbleSortB非常受益于分支预测(0.01%失误),但是当Limit = 50000时,分支预测失败更加严重(失误率为15.65%),而BubbleSortA的失误率分别为12.69%和12.76%。 BubbleSortA Limit=10:
Performance counter stats for './bubbleA.out':

   46670.947364 task-clock                #    0.998 CPUs utilized          
             73 context-switches          #    0.000 M/sec                  
             28 CPU-migrations            #    0.000 M/sec                  
            379 page-faults               #    0.000 M/sec                  
117,298,787,242 cycles                    #    2.513 GHz                    
117,471,719,598 instructions              #    1.00  insns per cycle        
 25,104,504,912 branches                  #  537.904 M/sec                  
  3,185,376,029 branch-misses             #   12.69% of all branches        

   46.779031563 seconds time elapsed

冒泡排序A 限制=50000:

Performance counter stats for './bubbleA.out':

   46023.785539 task-clock                #    0.998 CPUs utilized          
             59 context-switches          #    0.000 M/sec                  
              8 CPU-migrations            #    0.000 M/sec                  
            379 page-faults               #    0.000 M/sec                  
118,261,821,200 cycles                    #    2.570 GHz                    
119,230,362,230 instructions              #    1.01  insns per cycle        
 25,089,204,844 branches                  #  545.136 M/sec                  
  3,200,514,556 branch-misses             #   12.76% of all branches        

   46.126274884 seconds time elapsed

BubbleSortB Limit=10:

Performance counter stats for './bubbleB.out':

   26091.323705 task-clock                #    0.998 CPUs utilized          
             28 context-switches          #    0.000 M/sec                  
              2 CPU-migrations            #    0.000 M/sec                  
            379 page-faults               #    0.000 M/sec                  
 64,822,368,062 cycles                    #    2.484 GHz                    
137,780,774,165 instructions              #    2.13  insns per cycle        
 25,052,329,633 branches                  #  960.179 M/sec                  
      3,019,138 branch-misses             #    0.01% of all branches        

   26.149447493 seconds time elapsed

BubbleSortB Limit=50000:

Performance counter stats for './bubbleB.out':

   51644.210268 task-clock                #    0.983 CPUs utilized          
          2,138 context-switches          #    0.000 M/sec                  
             69 CPU-migrations            #    0.000 M/sec                  
            378 page-faults               #    0.000 M/sec                  
144,600,738,759 cycles                    #    2.800 GHz                    
124,273,104,207 instructions              #    0.86  insns per cycle        
 25,104,320,436 branches                  #  486.101 M/sec                  
  3,929,572,460 branch-misses             #   15.65% of all branches        

   52.511233236 seconds time elapsed

3

编辑2: 在大多数情况下,这个答案可能是错误的,当我说以上所有内容都是正确的时候,仍然是正确的,但对于大多数处理器架构来说,下面的部分不是真实的,请参见评论。然而,我要说的是,从理论上讲,仍然有可能存在某些操作系统/体系结构上的某些JVM会这样做,但那个JVM可能实现得很差或者是一个奇怪的架构。此外,从理论上讲,大多数可以想象的事情都是理论上可能的,因此我会带着一点怀疑看待最后一部分。

首先,我不确定关于C++,但我可以谈谈Java。

这里有一些代码,

public class Example {

    public static boolean less(final int a, final int b) {
        return a < b;
    }

    public static boolean lessOrEqual(final int a, final int b) {
        return a <= b;
    }
}

在它上面运行javap -c,我得到了字节码。
public class Example {
  public Example();
    Code:
       0: aload_0
       1: invokespecial #8                  // Method java/lang/Object."<init>":()V
       4: return

  public static boolean less(int, int);
    Code:
       0: iload_0
       1: iload_1
       2: if_icmpge     7
       5: iconst_1
       6: ireturn
       7: iconst_0
       8: ireturn

  public static boolean lessOrEqual(int, int);
    Code:
       0: iload_0
       1: iload_1
       2: if_icmpgt     7
       5: iconst_1
       6: ireturn
       7: iconst_0
       8: ireturn
}

你会注意到唯一的区别是if_icmpge(如果比较大于/等于)与if_icmpgt(如果比较大于)。上面的所有内容都是事实,其余部分是我根据我在汇编语言课程上学到的猜测。为了获得更好的答案,您应该查找您的JVM如何处理这些操作。我的猜测是C ++也编译成类似的操作。

编辑:有关if_i<cond>的文档在此处

计算机比较数字的方式是将其中一个数减去另一个数,并检查该数字是否为0,所以当进行 a < b 操作时,它从 a 中减去 b 并检查结果是否小于0,通过检查值的符号来确定 (b - a < 0)。但是,要执行 a <= b,则需要再减去1个数字 (b - a - 1 < 0)。

通常,这是一种非常微小的差异,但这不是任何代码,这是可怕的气泡排序!因为它在最内部的循环中,平均要执行 O(n^2) 次此特定比较。

是的,这可能与分支预测有关,我不确定,我不是专家,但我认为这也可能扮演一个非常重要的角色。


我认为你关于<<=更快是不正确的。处理器指令是离散化的;每个指令必须占用整数个时钟周期--除非你可以挤出一个完整的时钟,否则就没有“节省时间”的说法。请参见https://dev59.com/bmct5IYBdhLWcg3wXsbH#12135533。 - kevinsa5
请注意,我所说的是仅限于本地代码。我猜测JVM实现可能会执行这种“优化”,但我认为它只会使用本地指令而不是自己设计解决方案。但这只是我的猜测。 - kevinsa5
4
你基于什么断言 <= 使用额外步骤减去1?例如,在x86级别上,一个 "cmp" 后跟一个 "jl",与一个 "cmp" 后跟一个 "jle",在成功的分支预测下,将需要完全相同的时间。更多细节请参考https://dev59.com/bmct5IYBdhLWcg3wXsbH。 - ClickRick
@ClickRick 我学习的汇编语言是针对使用精简指令集的SPARC处理器的。也许它没有jle指令?或者我在某个地方听到了这个错误的假设。现在我不确定我从哪里得到这个信息。理论上,任何特定操作系统/架构的JVM如何解释它可能会有所不同,但我现在认为它们都可以在单个周期内完成。 - Captain Man
2
@CaptainMan 根据http://www.cs.northwestern.edu/~agupta/_projects/sparc_simulator/SPARC%20INSTRUCTION%20SET-1.htm,SPARC支持`bl`和`ble`指令,这对我来说完全不足为奇。 - ClickRick
@Click 我真的不知道我是怎么到达这个地步的。也许在学习时听到类似 for (int i = 0; i <= thing.size() - 1; i++) 的语句需要执行额外的操作,多年来将其与 <= 关联起来(减一并没有帮助哈哈)。我会添加一个编辑说明,除非某些特定的操作系统/架构实现了 JVM,否则我的答案是错误的。 - Captain Man

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