【问题标题】:> vs. >= in bubble sort causes significant performance difference> vs. >= 在冒泡排序中导致显着的性能差异
【发布时间】:2015-07-25 05:59:17
【问题描述】:

我只是偶然发现了一些东西。起初我认为这可能是分支错误预测的情况,例如 in this case,但我无法解释为什么分支错误预测会导致这种行为。

我用 Java 实现了两个版本的冒泡排序并做了一些性能测试:

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

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

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 的参数设置为例如50000 (java 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不会改变结果。

【问题讨论】:

  • 你是如何测量时间的?如果您只测量一种情况的时间,那么时间将很大程度上取决于随机序列,&gt;&gt;= 的影响很小。要获得真正有意义的次数,您必须测量许多不同的序列和平均值
  • @tobi303 看代码。您可以通过第三个运行时参数(Java)或-DRUNS=XXX(C++,编译器指令)在循环中运行它。并且结果是可重现的。
  • 计算这两种情况下的交换次数会很有趣,以了解这与运行时有何关系。我的意思是,如果 A 较慢,这绝对不是因为交换次数,所以如果 A 更快,原因也不仅仅是交换次数,而是一些更微妙的影响
  • @Turing85:但是你重新运行测试了吗?
  • 先调用bubbleSortB(),然后调用bubbleSortA(),看看结果是否成立也很有趣。使用 Java,我经常怀疑内存分配和 gc 会导致意外结果。尽管在 C++ 中获得相同的结果表明这里正在发生更普遍的事情。

标签: java c++ performance optimization


【解决方案1】:

我认为这确实可以用分支错误预测来解释。

例如,考虑 LIMIT=11 和 sortB。在外部循环的第一次迭代中,它会很快偶然发现一个等于 10 的元素。所以它会有a[j]=10,因此a[j] 肯定是&gt;=a[next],因为没有大于10. 因此,它会执行一次交换,然后在j 中执行一步,却再次找到a[j]=10(相同的交换值)。所以又是a[j]&gt;=a[next],等等。除了一开始的几个比较外,所有比较都是正确的。同样,它将在外循环的下一次迭代中运行。

sortA 不一样。它将以大致相同的方式开始,偶然发现a[j]=10,以类似的方式进行一些交换,但只是在它也找到a[next]=10 的时候。然后条件为假,不会进行交换。以此类推:每次遇到a[next]=10 时,条件为假且未进行任何交换。因此,这个条件在 11 次中有 10 次为真(a[next] 的值从 0 到 9),在 11 次中有 1 次为假。分支预测失败并不奇怪。

【讨论】:

    【解决方案2】:

    我认为这确实可能是由于分支预测。如果将交换次数与您找到的内部排序迭代次数进行比较:

    限制 = 10

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

    限制 = 50000

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

    所以在Limit == 10 的情况下,交换在 B 排序中执行的时间为 99.98%,这显然有利于分支预测器。在Limit == 50000 的情况下,交换仅随机命中 68%,因此分支预测器的作用不大。

    【讨论】:

    • 你的论点似乎是明智的。有什么方法可以检验你的假设吗?
    • 快速回答是将输入数组控制为某种东西,以便 A/B 的排序以相同的顺序(至少大致如此)进行相同的交换。具体怎么做我不知道。您还可以查看交换顺序“不知何故”的随机性,看看这是否与排序时间相关。
    • 对于LIMIT &gt;= ARRAY_SIZE 的情况,您可以做一个数组由唯一数字组成的测试用例。例如,在a[i] = ARRAY_SIZE - i 的情况下,您会在每个循环上获得一个交换,并且 A/B 排序的时间相同。
    • @Turing85,请注意,我的回答实际上解释了,为什么这是交换次数的差异。
    • @Petr 为什么有大量的交换对我来说是显而易见的。我只是无法将这个事实与分支错误预测联系起来。所选择的答案(在我看来)给出了最好的解释和最好的论证。
    【解决方案3】:

    编辑 2: 这个答案在大多数情况下可能是错误的,当我说以上所有内容都正确时,较低的部分仍然是正确的,但对于大多数处理器架构来说,较低的部分不是正确的,请参阅 cmets。但是,我会说它仍然理论上可能在某些操作系统/架构上存在一些 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(如果比较大于)。

    上面的一切都是事实,剩下的就是我对如何处理 if_icmpgeif_icmpgt 的最佳猜测,基于我参加的一门汇编语言课程。要获得更好的答案,您应该查看 JVM 如何处理这些问题。我的猜测是 C++ 也可以编译成类似的操作。

    编辑:if_i&lt;cond&gt; 上的文档是 here

    计算机比较数字的方式是从另一个减去一个并检查该数字是否为0,因此在执行a &lt; b if 时,如果从a 中减去b,并通过检查查看结果是否小于0值的符号 (b - a &lt; 0)。要执行a &lt;= b,尽管它必须执行额外的步骤并减去 1 (b - a - 1 &lt; 0)。

    通常这是一个非常微小的差异,但这不是任何代码,这是该死的冒泡排序! O(n^2) 是我们进行此特定比较的平均次数,因为它位于最内层循环中。

    是的,它可能与分支预测有关,我不确定,我不是这方面的专家,但我认为这也可能起到不重要的作用。

    【讨论】:

    • 我不认为你对 &lt;&lt;= 快的说法是正确的。处理器指令是离散化的;每条指令必须占用整数个时钟周期——没有“节省时间”,除非你能从中挤出一个完整的时钟。见stackoverflow.com/a/12135533
    • 请注意,我只是在谈论本机代码。我想 JVM 实现可能会执行这种“优化”,但我猜它只会使用本机指令而不是制作自己的解决方案。但这只是猜测。
    • 您断言 cmp 后跟 jl 将花费与 cmp 后跟 jle 完全相同的时间(如果允许成功分支预测)。 stackoverflow.com/questions/12135518/is-faster-than 有更多详情。
    • @ClickRick 我学到的程序集是用于 SPARC 的,它使用了精简指令集。也许它没有jle?或者也许我也在某个地方听到了这个错误的假设。不是 100% 确定我从哪里得到的,因为我真的考虑过了。我想从理论上讲,尽管任何特定操作系统/架构的 JVM 对它的解释可能会有所不同,但我现在假设它们都在一个周期内完成。
    • @CaptainMan 根据cs.northwestern.edu/~agupta/_projects/sparc_simulator/…,SPARC 支持blble 指令,这对我来说完全不足为奇。
    【解决方案4】:

    使用 perf stat 命令提供的 C++ 代码(删除了计时),我得到了证实 brach-miss 理论的结果。

    使用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
    

    BubbleSortA 限制=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 限制=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 限制=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
    

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 2012-03-25
      • 2012-10-06
      • 1970-01-01
      • 2013-04-01
      • 2016-02-23
      • 2017-07-04
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多