`
RednaxelaFX
  • 浏览: 3049341 次
  • 性别: Icon_minigender_1
  • 来自: 海外
社区版块
存档分类
最新评论

降序循环总是比升序循环快?

阅读更多
刚才看到有人在论坛Java版的一帖里提到:
wujiazhao88 写道
如果有两层以上的循环,要将多次计算的循环放在里面,少的放在外面;
另外for(int i=n;i>0;i--)的效率比for(int i=0;i<n;i++)的效率高

Well...关于循环顺序的问题,这个笼统的说降序循环比升序循环效率高恐怕还是比较有误导性的,因为不是所有环境下都如此。

在许多体系结构上,整数的算术运算会设置条件码,诸如“是否有算术溢出”、“是否大于零”、“是否小于零”、“是否为零”,之类的。
例如在x86上,inc指令会更新OF、SF、ZF、AF、PF标志位,并保留CF标志位的值;add指令则会更新OF、SF、ZF、AF、CF、PF标志位。其中,
OF: overflow,算术溢出标志位;0为无溢出,1为有溢出
SF: sign flag,符号标志位;0为正或零,1为负
ZF: zero flag,零标志位;0为非零值,1为零
AF: adjust flag,调整标志位;这个主要用于BCD算术,一般的补码运算不会用到它
CF: carry flag,进位标志位;0为无进位,1为有进位
PF: parity flag,奇偶校验标志位;只检查结果的最低有效字节中二进制位为1的个数的奇偶性,0为个数是奇数,1为个数是偶数

要比较两个数的大小,并根据条件进行跳转,在x86上要怎么做呢?一般是用cmp指令与带调教的jmp指令组合使用。cmp其实就是普通减法,只不过它不保存减的结果,只用于设置标志位;跟add一样,它也会更新OF、SF、ZF、AF、CF、PF标志位。带条件的jmp会根据相应的标志位为0或1决定是跳转到指定的目标还是继续执行下一条指令。
举例如下:
(NASM语法)
cmp eax, ecx
jl  LABEL

(jl是jump if less)
就是:
if (eax < ecx) {
    goto LABEL;
}

// ...

LABEL:
  // ...

但如果是要跟0比较大小,就有机会不用cmp,而是直接靠之前的整数运算设置的标志位来控制跳转。本来降序循环的优势就是基于这一点:由于循环条件是与0比较的,编译器在优化的时候就可以少生成一条cmp指令。
升序循环可能是这样的:
for (int i = 0; i < N; i++) {
    // ...
}

      xor ecx, ecx  ; 循环变量清零
LOOP:               ; ...循环体内容
      inc ecx       ; 循环变量自增
      cmp ecx, N    ; 循环条件
      jl  LOOP      ; 小于的时候继续循环
NEXT:
; ...循环之后别的代码

在执行了inc后,它设置的标志位全部都没能用上,被cmp全部冲掉了之后才决定是否跳转。

降序循环可能是这样的:
for (int i = (N - 1); i >= 0; i--) {
    // ...
}

      mov ecx, N    ; 循环变量初始化到N-1
      dec ecx
LOOP:               ; ...循环体内容
      dec ecx       ; 循环变量自减,同时也设置了标志位,构成循环条件
      jge LOOP      ; 大于等于的时候继续循环
NEXT:
; ...循环之后别的代码

dec在完成自减的同时也设置了标志位,紧接着带条件的jmp就用上了标志位。这样就比升序循环少一条指令。爽不?

问题是我们手写汇编的话很容易控制运行时准确的行为,但首先程序有可能是被解释执行的,其次编译器也未必一定会做这种优化。


于是上微型测试来看看。实验环境是Sun的JDK 1.6.0 update 14 server VM,Windows XP SP3 32位,Core 2 Duo。
代码如下:
public class Test {
    private static void foo(int[] src, int[] dest) {
        for (int i = 0; i < src.length; i++) {
            dest[i] = src[i];
        }
    }
    
    private static void bar(int[] src, int[] dest) {
        for (int i = src.length - 1; i >= 0; i--) {
            dest[i] = src[i];
        }
    }
    
    public static void main(String[] args) {
        int[] dummy = new int[10000];
        
        for (int i = 0; i < 3000; i++) {
            foo(dummy, dummy);
        }
        
        for (int i = 0; i < 3000; i++) {
            bar(dummy, dummy);
        }
    }
}

没有在代码里测时间,在main()里循环调用foo()和bar()只是为了激发它们被HotSpot编译而已。

先看上面的Java源码被javac编译为字节码之后的样子:
foo()
0:   iconst_0
1:   istore_2
2:   iload_2
3:   aload_0
4:   arraylength
5:   if_icmpge    20
8:   aload_1
9:   iload_2
10:  aload_0
11:  iload_2
12:  iaload
13:  iastore
14:  iinc    2, 1
17:  goto    2
20:  return


bar()
0:   aload_0
1:   arraylength
2:   iconst_1
3:   isub
4:   istore_2
5:   iload_2
6:   iflt    21
9:   aload_1
10:  iload_2
11:  aload_0
12:  iload_2
13:  iaload
14:  iastore
15:  iinc    2, -1
18:  goto    5
21:  return

JVM在概念上并没有条件码,而是用if-*指令根据当前求值栈顶的两个值(或者栈顶值与0)的关系)决定是否跳转。从字节码上看,升序循环与降序循环没有多少差异。上面的例子中,foo()中的if_icmpge指令是把栈顶两个值弹出来做比较,而bar()中的iflt指令是把栈顶的一个值与弹出来与0比较,与前面提到的省一条cmp指令的优化有异曲同工之妙。

这里foo()与bar()比较显著的不同来自是:前者每轮循环都要执行arraylength指令用于获取数组长度,而后者只在循环开头处要执行一次arraylength指令。这只是因为javac基本上不做优化而已。如果手动把foo()中数组length提取到循环外,如下
private static void foo(int[] src, int[] dest) {
    int length = src.length;
    for (int i = 0; i < length; i++) {
        dest[i] = src[i];
    }
}

那对应的字节码就变成:
0:  aload_0
1:  arraylength
2:  istore_2
3:  iconst_0
4:  istore_3
5:  iload_3
6:  iload_2
7:  if_icmpge  22
10: aload_1
11: iload_3
12: aload_0
13: iload_3
14: iaload
15: iastore
16: iinc  3, 1
19: goto  5
22: return

这样就与bar()的字节码更加相似了,arraylength指令不在循环体内出现。这个版本的foo()与bar()在解释模式上执行的性能特征没有区别。

如果foo()与bar()被以解释模式执行,而解释器又不做什么优化的话,字节码形式的代码倒也能反映出运行时的性能特点。但像HotSpot这样的动态编译器会选择最耗时间的代码(也就是“热点”)进行编译,实际上代码是被编译到native code来执行的。
那么HotSpot的server VM中的编译器分别把foo()与bar()编译成了怎样的native code呢?下面把两者的循环体部分的代码贴出来:(AT&T语法)
foo()
0x00becc80: movq   0xc(%ecx,%edi,4),%xmm0
0x00becc86: movq   %xmm0,0xc(%edx,%edi,4)
0x00becc8c: movq   0x14(%ecx,%edi,4),%xmm0
0x00becc92: movq   %xmm0,0x14(%edx,%edi,4)
0x00becc98: movq   0x1c(%ecx,%edi,4),%xmm0
0x00becc9e: movq   %xmm0,0x1c(%edx,%edi,4)
0x00becca4: movq   0x24(%ecx,%edi,4),%xmm0
0x00beccaa: movq   %xmm0,0x24(%edx,%edi,4)
0x00beccb0: movq   0x2c(%ecx,%edi,4),%xmm0
0x00beccb6: movq   %xmm0,0x2c(%edx,%edi,4)
0x00beccbc: movq   0x34(%ecx,%edi,4),%xmm0
0x00beccc2: movq   %xmm0,0x34(%edx,%edi,4)
0x00beccc8: movq   0x3c(%ecx,%edi,4),%xmm0
0x00beccce: movq   %xmm0,0x3c(%edx,%edi,4)
0x00beccd4: movq   0x44(%ecx,%edi,4),%xmm0
0x00beccda: movq   %xmm0,0x44(%edx,%edi,4)
0x00becce0: add    $0x10,%edi
0x00becce3: cmp    %ebp,%edi
0x00becce5: jl     0x00becc80


bar()
0x00bed1b0: lea    (%edx,%esi,4),%ebp
0x00bed1b3: mov    0x4(%esp),%ebx
0x00bed1b7: lea    (%ebx,%esi,4),%ebx
0x00bed1ba: movq   0x8(%ebx),%xmm0
0x00bed1bf: movq   %xmm0,0x8(%ebp)
0x00bed1c4: movq   (%ebx),%xmm0
0x00bed1c8: movq   %xmm0,0x0(%ebp)
0x00bed1cd: movq   -0x8(%ebx),%xmm0
0x00bed1d2: movq   %xmm0,-0x8(%ebp)
0x00bed1d7: movq   -0x10(%ebx),%xmm0
0x00bed1dc: movq   %xmm0,-0x10(%ebp)
0x00bed1e1: movq   -0x18(%ebx),%xmm0
0x00bed1e6: movq   %xmm0,-0x18(%ebp)
0x00bed1eb: movq   -0x20(%ebx),%xmm0
0x00bed1f0: movq   %xmm0,-0x20(%ebp)
0x00bed1f5: movq   -0x28(%ebx),%xmm0
0x00bed1fa: movq   %xmm0,-0x28(%ebp)
0x00bed1ff: movq   -0x30(%ebx),%xmm0
0x00bed204: movq   %xmm0,-0x30(%ebp)
0x00bed209: add    $0xfffffff0,%esi
0x00bed20c: cmp    %ecx,%esi
0x00bed20e: jg     0x00bed1b0


看起来黑压压的……是什么来的?
mov是x86上移动数据用的指令,q是AT&T语法中表示数据宽度为QWORD(64位整型)指令后缀。循环体中连续的出现一大堆movq可以这样看:
movq   0xc(%ecx,%edi,4),%xmm0
movq   %xmm0,0xc(%edx,%edi,4)

这样的一组指令先把源数组里连续的64位数据移动到一个寄存器(%xmm0)上,然后再把数据从寄存器移到目标数组里。要分两条是因为x86的数据移动指令接受源与目标两个参数,但其中只能有一个是内存参数。

Java里int不是32位的么?为什么要移动64位呢?而且原本代码里每轮循环只复制了一个int,为什么编译出来之后循环体里有那么多组movq?
这就是编译器的一种常见优化技巧,“循环展开”(loop unrolling)的体现。通过提高每轮循环做的工作量,循环次数就可以显著减少,相应的就可以降低循环带来的开销。这个例子中foo()与bar()的循环体都被优化展开,然后通过“superword”优化重新组合成向量化的、更大数据宽度的操作,每轮循环复制8个QWORD也就是16个int。

将两个int复制合并为一个64位复制操作的优化由以下参数控制:
product(bool, UseSuperWord, true, "Transform scalar operations into superword operations")

循环展开的优化则由以下一些参数控制:
product_pd(intx,  LoopUnrollLimit, "Unroll loop bodies with node count less than this")
product(intx,  LoopUnrollMin, 4, "Minimum number of unroll loop bodies before checking progress of rounds of unroll,optimize,..")


但……在本文前半部分提到降序循环可以减少一个cmp指令的优化,HotSpot却没有做。在bar()里还是可以看到做了减法(add 0xFFFFFFF0就是减去16)还是有cmp指令;当然咯,这里实际不是固定与0在做比较。每轮循环都复制16个int,那如果数组长度不是16的倍数怎么办呢?头尾多出来的部分就得用别的代码来处理了。因此这个主循环结束时,%esi可能还没到0,就无法消除cmp指令。

在经过的间接层越多的语言实现中,我们就越难把握实际运行时执行的代码的状况。也正是因为如此,一些在底层语言(例如C)里有效的优化技巧在较高级的语言中(例如Java)中就未必适用。使用错误的抽象层次去思考优化技巧是比较危险的……还是优先考虑代码的整洁性、可阅读性来得实在些。

===========================================================================

有趣的是,HotSpot server VM给foo()编译出来的代码与bar()的应用的优化取舍有点不同。可以看到foo()中每条movq指令的内存参数都是“基地址+放大倍数×偏移量”的方式计算地址的,而bar()中则是在循环体的开头处先用“基地址+放大倍数×偏移量”的方式算出了一个中间结果,后面就直接用“基地址+偏移量”的方式计算地址。看起来应该是后者更高效些。不过嘛……这里差得不会太多就是了。而且这个差异跟升序/降序没有直接关系,纯粹是HotSpot有RPWT,居然一个有CSE的效果另一个没有……所以单独开一段放在结尾提一提就算了 orz

===========================================================================

另一个题外话:Array Bounds Check Elimination in the CLR
在这篇文章里,Dave Detlefs讲解了当时CLR在做数组边界检查削除优化的一些场景和限制。当时CLR的JIT编译器在升序的计数循环里能削除数组边界检查,而在降序循环里就没做削除。这会使得降序循环里访问数组元素比较慢。
像这种由编译器实现而造成升序/降序循环速度差异的例子并不是仅此一家。靠拍脑袋去猜测然后说某种形式的代码一定快于另一种形式的代码,自然是不靠谱的。

Have fun ^_^
9
3
分享到:
评论
9 楼 RednaxelaFX 2009-12-11  
andyu2008 写道
本人严重不支持两层循环,第二层循环完全可以使用Map代替!

这个……跟本文有什么关系么?
这帖里main的循环只是为了引发foo()与bar()被HotSpot执行的被编译为本地代码而已。真正的测试对象是foo()与bar()中的单层循环。
8 楼 mooninday 2009-12-11  
LZ很强大, 我没领会要领
7 楼 andyu2008 2009-12-11  
本人严重不支持两层循环,第二层循环完全可以使用Map代替!
6 楼 wujiazhao88 2009-12-10  
呵呵,说得好,还是楼主理解深刻,我危言耸听了。。 
5 楼 RednaxelaFX 2009-12-10  
其实这篇就是杂技4的1/3内容的缩减版……之前有些人看过草稿了,不过还是太零碎,组织好之前都不太想发…… =_=|||

seen 写道
如果能深入一点就更好 比如说说循环中localization对性能的影响 以及L1/L2 cache对性能的影响

杂记1提到过locality。这篇是看到了论坛的讨论实在忍不住就写了点针对性的……好吧事实是再深入下去我就漏底了 XDD
4 楼 幸存者 2009-12-10  
刚才找了下,那篇杂记1:缓存,局部性,循环太监了,说起来楼主太监的系列不少。印象中某篇“杂记3”因为不满楼主的行为从草稿箱里跳了出来。
3 楼 幸存者 2009-12-10  
我记得楼主以前发过一篇关于locality的文章。
2 楼 seen 2009-12-10  
能把一个老话题说的这么生动还是蛮有能力的
如果能深入一点就更好 比如说说循环中localization对性能的影响 以及L1/L2 cache对性能的影响
1 楼 幸存者 2009-12-10  
原来HotSpot还有这样的优化技巧。
文中只贴了循环体内的汇编码,我想知道HotSpot操作数组的时候作不作边界检查,或者在循环体外检查的?如果不作的话,是不是可以干点坏事呢,呵呵。

相关推荐

    JavaScript实现表格排序,点击表头切换升序降序,非常简单

    这个函数首先获取表格和其所有行,然后在每轮循环中比较相邻两行的指定列(由`n`参数决定)。如果当前为升序,那么当某行的值大于下一行时进行交换;如果是降序,就反过来。当没有更多的交换操作时,结束排序。 CSS...

    java冒泡排序(可处理各种异常,选择升序还是降序)

    该方法可以接受一个布尔参数`ascending`,来决定是否按升序(默认)或降序进行排序。为了处理可能的异常,我们需要对输入的数组长度为零或者为负的情况做出响应,并添加适当的异常处理机制。 以下是一个简单的Java...

    VBA数组的升序、降序.docx

    VBA 数组的升序、降序排序 VBA 数组的升序、降序排序是 VBA 编程中的一种常见操作,用于对数组中的元素进行排序。本文将介绍 VBA 数组的升序和降序排序的方法,并提供相应的代码示例。 一、升序排序 升序排序是指...

    java 实现冒泡排序升序降序

    1.对int数组进行排序,sortAsc 升序,sortDesc降序 2.创建main函数,声明一个int数组,随机写入一些数字 3.sortAsc进行升序排列 4.使用双重for循环进行排序,当第一个数字大于第二个数字, 则交换位置 5.sortDesc则...

    TIA博途SCL语言冒泡排序算法FC全局库文件(可选升序降序)GF-bubble-Sort.zip

    本篇我们将深入探讨TIA博途SCL语言中的冒泡排序算法,并介绍如何创建一个全局函数块(FC)库,实现升序或降序的排序功能。 冒泡排序是一种简单但效率较低的排序算法,它的基本思想是通过重复遍历待排序的序列,比较...

    俄罗斯套娃信封问题(一维升序 二维降序)1

    标题中的“俄罗斯套娃信封问题(一维升序 二维降序)1”是指一个经典的计算机科学问题,它在算法设计和优化领域有广泛的应用。这个问题的核心是找到一种方式来组织一组信封,使得一个信封可以完全放入另一个信封内,...

    学生信息的动态输入和降序输出

    在IT领域,尤其是在系统编程和数据结构中,动态输入和降序输出是常见的操作,尤其在处理用户数据或实现特定算法时。这个项目名为“学生信息的动态输入和降序输出”,它用C语言在Linux平台上实现了这一功能。C语言是...

    TIA博途SCL语言快速排序算法全局FC库文件(可选升序降序)GF-quick-Sort.zip

    本篇文章将深入探讨TIA博途SCL语言中的快速排序算法,以及如何将其封装为一个全局功能块(FC)库文件,以便在项目中重复使用,并支持升序或降序排列。 快速排序是一种高效的排序算法,由英国计算机科学家C.A.R. ...

    S7-200SMART冒泡排序-优化版(可选择升序降序及数据类型等).zip

    为了实现升序或降序的选择,我们可以添加一个输入变量,如“SortOrder”,当其值为1时代表升序,0代表降序。在比较元素的过程中,根据该变量的值决定是否进行交换。例如: ```st IF SortOrder THEN // 升序排序 ...

    一次循环简化冒泡排序.txt

    - **排序降序(paiXu2 方法)**:与升序类似,但在降序排序时,如果当前元素小于下一个元素,则进行交换,并同样将索引重置为初始值。 这种方法的核心在于利用一个无限循环来模拟多轮遍历的过程,直到数组完全有序...

    循环中的数据操作

    - 数据排序:利用循环对数组或集合进行升序或降序排序。 - 数据过滤:通过条件判断在循环中筛选出满足特定条件的数据。 - 数据计算:例如求平均值、最大值、最小值等统计操作。 - 数据转换:将数据从一种格式转换为...

    rain-flow.rar_matlab 雨流法_matlab雨流计数_雨流循环_雨流法_雨流计数法法

    2. **排序与极性转换**:数据必须按时间顺序排列,并进行极性转换,确保升序和降序的交替,这可以通过排序函数如`sort()`实现。 3. **差分计算**:计算连续时间点之间的应力或应变差值,这通常是通过减法操作完成的...

    冒泡排序代码

    冒泡排序的基本思想是从数组的第一个元素开始,依次比较相邻的两个元素,如果前一个比后一个大(升序排序),就交换它们的位置,这样一次遍历下来,最大的元素就会被排到最后。然后再从第一个元素开始做同样的操作,...

    输入N个学生的个人信息和成绩,然后按平均成绩的降序排列

    标题 "输入N个学生的个人信息和成绩,然后按平均成绩的降序排列" 涉及的是数据处理和排序算法的应用,主要关注如何收集学生信息、计算平均成绩以及根据平均成绩进行排序。在这个过程中,我们可以讨论以下几个关键...

    c++冒泡排序,从小到大排序或者从大到小

    在探讨C++冒泡排序这一知识点时,我们不仅会深入理解冒泡排序的基本原理、算法实现,还会细致分析如何在C++中灵活运用该算法进行数据的升序或降序排列。冒泡排序是一种简单的排序算法,其基本思想是通过重复地走访待...

    cycle_counting_3_三点法_雨流计数_雨流计数法_

    1. **数据排序**:首先,我们需要对原始的应力或应变时间序列进行排序,得到升序或降序的数据数组。这一步确保了后续的分析基于有序数据,简化了查找局部极值的过程。 2. **查找峰谷点**:在排序后的序列中,找出...

    循环有序数组查找问题1

    在这个问题中,数组是循环有序的,即数组中存在一段连续的子数组是有序的,但整个数组并不一定是从头到尾的升序或降序。 循环有序数组有三种可能的情况: 1) 最小值位于(s, m】之间,满足 m ,此时s到m之间的子...

    学生成绩管理系统V.doc

    2. **菜单选择**:系统提供了一个直观的菜单供用户选择操作,包括输入记录、计算总分和平均分、降序排序成绩、升序排序成绩、按学号升序排序、按学号搜索、统计分析以及列出所有记录等。用户通过输入数字选择对应的...

    labview-sum.rar_SUM_数组_数组labview_数组循环

    查找函数可以帮助定位数组中的特定元素,排序函数可以按升序或降序排列数组,而过滤函数则可以筛选出满足特定条件的元素。 总的来说,"labview sum.vi"这个例子是一个很好的学习资源,它教你如何在LabVIEW中处理...

    Java有序非循环双向链表算法实例

    有序链表要求元素按照某种规则(如升序或降序)排列。在插入新元素时,需要找到合适的位置保持这种有序性。这通常涉及到查找操作,可以采用折半查找、二分查找等算法提高效率。 三、非循环特性 非循环链表的尾部...

Global site tag (gtag.js) - Google Analytics