数组/链接列表:性能取决于遍历的*方向*?

时间:2012-12-14 04:30:06

标签: java android arrays linked-list iteration

这篇文章分为两个主要部分。第一部分介绍了原始测试用例和结果,以及我对它的看法。第二部分详细介绍了修改后的测试用例及其结果。

该主题的原始标题是"对阵列的完全迭代明显快于链接列表"。由于更新的测试结果(见第二部分),标题发生了变化。


第一部分:原始测试用例

对于完整的单向顺序遍历,已知链表和数组具有相似的性能,但由于连续数组的缓存友好性(引用局部性),它可能会执行(略) )更好。为了了解它在实践中是如何工作的(Android,Java),我检查了上述说法,并进行了一些测量。

首先,我天真的假设。我们来看看下面的课程:

private static class Message {
    public int value1;
    public Message next;

    public Message(java.util.Random r, Message nextmsg) {
        value1 = r.nextInt();
        next = nextmsg;
    }
}

在第一个测量方案中,根本不会使用其next字段。下面的代码创建了一个包含1,000,000个Message实例的数组,然后在循环中遍历数组。它测量迭代花费的时间。

Log.i("TEST", "Preparing...");          

final int cnt = 1000000;
int val = 0;
java.util.Random r = new java.util.Random();
Message[] messages = new Message[cnt];
for (int i = 0; i < cnt; i++) {
    messages[i] = new Message(r, null);
}           

Log.i("TEST", "Starting iterating...");
long start = SystemClock.uptimeMillis();

for (int i = 0; i < cnt; i++) {
    Message msg = messages[i];
    if (msg.value1 > 564645) {
        val++;
    }
}       
Log.w("TEST", "Time: " + (SystemClock.uptimeMillis() - start) + ", result: " + val);

第二个衡量标准构建并测量Message个对象的链接列表:

Log.i("TEST", "Preparing...");          

final int cnt = 1000000;
int val = 0;
java.util.Random r = new java.util.Random();
Message current = null;
Message previous = null;
for (int i = 0; i < cnt; i++) {
    current = new Message(r, previous);
    previous = current;
}
previous = null;

Log.i("TEST", "Starting iterating...");
long start = SystemClock.uptimeMillis();
while (current != null) {
    if (current.value1 > 564645) {
        val++;
    }
    current = current.next;
}           

Log.w("TEST","Time: " + (SystemClock.uptimeMillis() - start) + ", result: " + val);

第一次测试不断产生 41-44 ms,而第二次测试产生 80-85 ms。链表迭代似乎慢了100%。

我的(可能有缺陷的)思路和问题如下。我欢迎(事实上,鼓励)任何更正。

好的,我们经常可以读到一个数组是一个连续的内存块,因此顺序访问它的元素比链接列表更容易缓存。 在我们的例子中,数组的元素只是对象引用,而不是Message对象本身(在Java中,我们没有价值类型,即C#中的结构,我们可以存储在数组中)。因此,参考地点&#34;仅适用于数组元素本身,这些仅指定对象的地址。因此,Message实例(一般情况下)仍然可以在任何地方&#34;在记忆中,所以&#34;参考地点&#34;不适用于实例本身。从这一点来看,我们看起来与链表相同:实例本身可能位于&#34;任何地方&#34;在内存中:数组只保证引用存储在一个连续的块中......

...这里是用例:完整的顺序遍历(迭代)。首先,让我们来看看我们如何在每种情况下获得引用到实例。在阵列的情况下,它非常有效,因为它们位于连续的块中。 如果是链接列表,我们也很好,因为一旦我们访问了Message实例(这就是我们迭代的原因!),我们立即引用 next 实例。由于我们已经访问了Message字段,因此缓存应该支持访问另一个字段(&#34; next&#34;)(同一对象的字段也具有引用AFAIK,它们也在一个连续的块中。总而言之,它似乎可以分解为:

  1. 该数组提供了对引用的缓存友好迭代。 Message个实例本身可能是&#34;任何地方&#34;在记忆中,我们也需要访问这些。
  2. 链接列表提供了在访问当前Message实例时获取对下一个元素的引用。这是&#34; free&#34;,因为无论如何都必须访问每个Message实例(就像在数组中一样)。
  3. 因此,基于以上所述,看起来数组并不比链表更好。唯一的例外是当数组是基本类型时(但在这种情况下,将它与链表进行比较是没有意义的)。所以我希望他们表现相似,但他们没有,因为存在巨大的差异。实际上,如果我们假设每次访问元素时数组索引都需要进行范围检查,那么链表(理论上)可以更快,甚至更快。 (数组访问的范围检查可能是由JIT优化的,所以我知道这不是一个有效的点。)

    我的猜测如下:

    1. 可能不是数组的缓存友好性导致了100%的差异。相反,JIT执行在链表遍历的情况下无法完成的优化。如果消除了范围检查和(VM级别)空检查,那么我想&#34; array-get&#34;字节码指令可能比我的&#34; field-get&#34;更快。 (或称其中的任何内容)指令在链表情况下(?)。

    2. 即使Message个实例可以在任何地方&#34;在记忆中,他们可能彼此非常接近,因为他们在同一时间被分配了#34;。但是,1,000,000个实例不能被缓存,只有一部分被缓存。在这种情况下,顺序访问在数组和链表情况下都是缓存友好的,因此这并不能解释其中的差异。

    3. 一些聪明的预测&#34; Message实例的(预取)我会访问吗?即不知何故,Message实例本身仍然具有缓存友好性,但在数组访问的情况下只有

    4. 更新:由于收到了几条评论,我想在下面做出反应。

      @irreputable:

        

      从高地址到低地址访问链表。如果   反过来说,即下一个指向一个较新的对象,而不是一个   上一个对象

      非常好的地方!我没有想到这个小细节,布局可能会影响测试。我今天将对其进行测试,并将返回结果。 (编辑:结果在这里,我已经用&#34更新了这篇文章;第2节&#34;)。

      @Torben评论:

        

      另外我会说这整个练习似乎没用。您   正在谈论超过100000次迭代的4ms改进。似乎   过早优化。如果你有这种情况   瓶颈,然后请描述它,我们可以调查它(因为   它肯定会比这更有趣。)

      如果您对此不感兴趣,那么您可以忽略此主题(而不是发布4次)。关于你过早优化&#34;过早优化&#34; - 我担心你读得太多,工业级别的开发太少。具体情况是在与仿真相关的软件中,可能必须每秒多次遍历这些列表。实际上,120毫秒的延迟可能会影响应用程序的响应能力。

        

      我很欣赏你对此的想法,但我真的找不到   你的帖子提问。 :)编辑:数组迭代速度提高50%。   快100%意味着零消耗时间。

      我确信从我的帖子中可以清楚地看到,我想知道为什么存在非常显着的差异,而这些论点会暗示其他情况。感谢您的纠正:事实上,我想写一下链表案例慢100%。


      第二部分:修改过的测试用例

      无可争议的对我有一个非常有趣的观察:

        

      从高地址到低地址访问链表。如果   反过来说,即下一个指向一个较新的对象,而不是一个   上一个对象

      我更改了链表结构,使其next指针的方向等于其节点的实例化顺序:

      Message current = null;
      Message previous = new Message(r, null);
      Message first = previous;
      for (int i = 0; i < cnt; i++) {
          current = new Message(r, null);
          previous.next = current;
          previous = current;
      }       
      previous = current = null;
      

      (请注意,创建算法可能不是最紧凑的算法,我认为我知道一种稍微好一点的方法。)迭代这个链表的代码:

      while (first != null) {
          if (first.value1 > 564645) {
              val++;
          }
          first = first.next;
      }
      

      现在我得到的结果总是 37-39 ms(好吧,我们可以说它确实是数组的性能,但实际上,它是&#39; s在每个测试用例中,不断加快。)而不是 80 ms的&#34;反向&#34;链表,它的速度提高了一倍!

      然后我也用原始数组测试用例进行了类似的测试:我将数组遍历改为相反的方向(到倒计时循环):

      for (int i = cnt - 1; i >= 0; i--) {
          Message msg = messages[i];
          if (msg.value1 > 564645) {
              val++;
          }
      }
      

      结果总是 85-90 ms!最初的测试用例产生了40-41毫秒。

      现在似乎有两个新的结论(和一个问题):

      1. 最初的说法似乎是数组&#34;&#34;参考地点&#34; (由于有条件的记忆块)在&#34; reference-type&#34;的情况下没有提供优势 (即对象)数组与链接列表进行比较时。这是因为对象数组只将引用保存到对象实例,而不是对象实例本身(理论上可以是&#34;在内存中的任何位置&#34;就像在链表)。

      2. 在我的测试用例中,即使在数组场景(!)的情况下,结果似乎依赖于在遍历的方向。这怎么可能?

      3. 总结我的测试结果:

        1. In&#34; forward&#34;方向遍历,链表略好于数组遍历(完全符合预期:当获得Message实例时,我们立即获得 next 引用,即使不需要访问数组获取地址的要素。)

        2. &#34;落后&#34;方向遍历,两者的性能都差不多100%(链表也稍微优于阵列)。

        3. 有什么想法吗?

          更新1: dlthorpe 发表了非常有价值的评论。我会在这里复制它们,因为它们可能有助于找到这个&#34;谜语的答案。

            

          是否有任何迹象表明硬件实现了预见页面   内存缓存控制器中的预取?而不是只加载   内存引用所需的内存页面,也会加载下一个更高的内存页面   预期向前进行阅读的页面?这个会   消除页面加载等待通过内存的前进进程但是   不会消除页面加载等待反向进展   存储器中。

               

          [..]

               

          我建议测试完全不同的硬件。最流动的   设备正在运行某种形式的ARM SoC。查看测试用例是否显示   英特尔硬件上的类似偏差,如PC或Mac。如果你可以挖掘   一台旧的PowerPC Mac,甚至更好。如果这些没有显示出类似的结果,   然后,这将指向ARM平台或其独特的东西   Java实现。

               

          [..]

               

          是的,您的访问模式大多是顺序的,但不同   方向。如果您下面的某些东西正在进行预取,但仅在   一个方向(预取下一个更高的地址块),那就是   将结果倾向于支持那个方向的测试。

          更新2:我在PC上运行测试(从2009年2月起,Core i7 Nehalem架构,8 GB RAM,Windows 7)。我在.NET 2.0源代码项目中使用了C#.NET(但是在机器上安装了.NET 4)。我的结果有2500万Message个实例:

          • 链接列表: 57-60 ms
          • 数组: 60-63 ms

          阅读的方向似乎并没有影响结果。

2 个答案:

答案 0 :(得分:3)

谈到PC硬件,早期的硬件预取程序(比如大约2005年)在检测和预取前向访问方面更好,但是最近的硬件应该擅长检测两个方向。如果您对移动硬件感兴趣,它完全有可能仍然实现基本的仅向前预取。

在MMU中实现的正确预取之外,实际上检测访问模式,当发生高速缓存未命中时,硬件通常会获得多个高速缓存行。通常,当发生未命中时,除了所需的下一个高速缓存行之外,还需要这样做。通过在这种情况下有效地将缓存未命中率减半(这假设预取无效),此实现将使前向方向成为一个很大的优势。

在本地,在Core i7上,对于整个迭代,链接列表版本的结果为~3.3 ms,而阵列版本的结果为3.5 ms,当使用原始程序时(相反顺序迭代链接列表)创作)。所以我看不到你做的那种效果。

测试的内部循环,检查val的值,会产生很大的影响。除非JIT编译器足够智能以使用CMOV或类似的东西,否则当前循环将导致许多误预测。似乎在我的测试中,它是 - 因为我得到大约1 ns /迭代的小迭代计数适合L1。 1 ns(约3个周期)与完整分支误预测不一致。当我更改它以执行无条件的val + = msg.value1时,即使在1,000,000次迭代的情况下(甚至在L3中也不适合),数组版本得到了显着提升。

有趣的是,相同的转换(val + = msg.value1)使链表版本略微变慢。通过转换,在小的迭代次数(L2内部,两种方法在外部可比较)时,阵列版本的速度要快得多。来自卡尺:

  length method         ns linear runtime
     100  ARRAY       63.7 =
     100 LINKED      190.1 =
    1000  ARRAY      725.7 =
    1000 LINKED     1788.5 =
 1000000  ARRAY  2904083.2 ===
 1000000 LINKED  3043820.4 ===
10000000  ARRAY 23160128.5 ==========================
10000000 LINKED 25748352.0 ==============================

小迭代计数的行为更容易解释 - 必须使用指针追踪的链表在循环的每次迭代之间具有数据依赖性。也就是说,每次迭代都取决于前一次,因为要加载的地址来自前一个元素。该数组没有相同的数据依赖性 - 只有i的增量是相关的,而且速度非常快(我肯定在这里的寄存器中)。因此,在数组的情况下,循环可以更好地流水线化。

答案 1 :(得分:1)

我不知道答案,但我会从查看生成的字节码的大小开始。由于在数组的情况下,迭代次数是已知的(cnt是硬编码的和最终的),编译器可能已经内联了一些迭代,节省了跳转和比较指令。

另外,如果您了解程序在低级别层的工作原理,查看反汇编的字节码可能会给您一些提示。即使你不熟练使用汇编语言,也不难理解像你这样的简单程序(当我第一次看到一些反汇编的java代码时,我感到很惊讶)。

希望这有帮助。