为什么(variable1%variable2 == 0)效率低下?


179

我是Java的新手,并且昨晚正在运行一些代码,这确实让我感到困扰。我当时正在构建一个简单的程序,以在for循环中显示每个X输出,当我将模数用作variable % variablevs variable % 5000或诸如此类时,我注意到性能的大幅下降。有人可以向我解释这是什么原因吗?这样我会更好...

这是“有效的”代码(很抱歉,如果我语法有点错误,我现在不在使用该代码的计算机上)

long startNum = 0;
long stopNum = 1000000000L;

for (long i = startNum; i <= stopNum; i++){
    if (i % 50000 == 0) {
        System.out.println(i);
    }
}

这是“无效代码”

long startNum = 0;
long stopNum = 1000000000L;
long progressCheck = 50000;

for (long i = startNum; i <= stopNum; i++){
    if (i % progressCheck == 0) {
        System.out.println(i);
    }
}

请注意,我有一个日期变量来衡量差异,一旦它变长了,第一个变量要花费50毫秒,而另一个变量要花费12秒或类似的时间。如果您的PC比我的PC效率更高或更低,您可能不得不增加stopNum或降低progressCheck

我在网上寻找了这个问题,但找不到答案,也许我只是问的不对。

编辑:我没想到我的问题会如此受欢迎,我感谢所有答案。我确实在所花费的每个时间上进行了基准测试,效率低下的代码花费了更长的时间,即1/4秒vs. 10秒的付出或花费。尽管他们使用的是println,但是它们的用量相同,所以我不会想象这会造成很大的偏差,特别是因为差异是可重复的。至于答案,由于我是Java新手,所以我现在让投票决定哪个答案最好。我会在星期三之前选一个。

EDIT2:今晚我将进行另一个测试,在该测试中,代替模数,它只是增加一个变量,当它到达progressCheck时,它将执行一个测试,然后将该变量重置为0。

EDIT3.5:

我使用了此代码,下面将显示结果。.谢谢大家的出色帮助!我还尝试将long的short值与0进行比较,因此我的所有新检查都发生过“ 65536”次,从而使其重复次数相等。

public class Main {


    public static void main(String[] args) {

        long startNum = 0;
        long stopNum = 1000000000L;
        long progressCheck = 65536;
        final long finalProgressCheck = 50000;
        long date;

        // using a fixed value
        date = System.currentTimeMillis();
        for (long i = startNum; i <= stopNum; i++) {
            if (i % 65536 == 0) {
                System.out.println(i);
            }
        }
        long final1 = System.currentTimeMillis() - date;
        date = System.currentTimeMillis();
        //using a variable
        for (long i = startNum; i <= stopNum; i++) {
            if (i % progressCheck == 0) {
                System.out.println(i);
            }
        }
        long final2 = System.currentTimeMillis() - date;
        date = System.currentTimeMillis();

        // using a final declared variable
        for (long i = startNum; i <= stopNum; i++) {
            if (i % finalProgressCheck == 0) {
                System.out.println(i);
            }
        }
        long final3 = System.currentTimeMillis() - date;
        date = System.currentTimeMillis();
        // using increments to determine progressCheck
        int increment = 0;
        for (long i = startNum; i <= stopNum; i++) {
            if (increment == 65536) {
                System.out.println(i);
                increment = 0;
            }
            increment++;

        }

        //using a short conversion
        long final4 = System.currentTimeMillis() - date;
        date = System.currentTimeMillis();
        for (long i = startNum; i <= stopNum; i++) {
            if ((short)i == 0) {
                System.out.println(i);
            }
        }
        long final5 = System.currentTimeMillis() - date;

                System.out.println(
                "\nfixed = " + final1 + " ms " + "\nvariable = " + final2 + " ms " + "\nfinal variable = " + final3 + " ms " + "\nincrement = " + final4 + " ms" + "\nShort Conversion = " + final5 + " ms");
    }
}

结果:

  • 固定= 874毫秒(通常为1000毫秒左右,但由于为2的幂,所以更快)
  • 变量= 8590毫秒
  • 最终变量= 1944毫秒(使用50000时约为1000毫秒)
  • 增量= 1904 ms
  • 短转换= 679毫秒

不足为奇的是,由于缺乏划分,短转换比“快速”方式快23%。这很有趣。如果您需要每256次(或大约256次)显示或比较某项内容,可以执行此操作,并使用

if ((byte)integer == 0) {'Perform progress check code here'}

一个最终的注意事项,在“最终声明的变量”上使用模数为65536(不是一个相当大的数字)的速度是固定值的一半(速度较慢)。它之前以相同的速度进行基准测试的地方。


29
我实际上得到了相同的结果。在我的机器上,第一个循环大约需要1.5秒,第二个大约需要9秒。如果我finalprogressCheck变量前面添加,则两者将再次以相同的速度运行。这使我相信,编译器或JIT在知道循环progressCheck不变的情况下便设法优化循环。
marstran


24
除以常数可以很容易地通过乘法逆转换为乘法。用变量除不能。并且32位除法比x86上的64位除法更快
phuclv

2
@phuclv注意,这里不是32位除法的问题,在两种情况下都是64位余数运算
user85421 '19

4
@RobertCotterman,如果您将变量声明为final,则编译器将创建与使用常量(eclipse / Java 11)相同的字节码((尽管对变量使用了更多的内存插槽))
user85421

Answers:


139

您正在测量OSR(堆栈上替换)存根。

OSR存根是已编译方法的特殊版本,专门用于在方法运行时将执行从解释模式转换为已编译代码。

OSR存根不如常规方法那样优化,因为它们需要与解释的帧兼容的帧布局。我在下面的回答表明这已经:123

类似的事情也在这里发生。当“低效率代码”运行较长的循环时,该方法专门为循环内的堆栈替换而编译。状态从解释的帧转移到OSR编译的方法,并且此状态包括progressCheck局部变量。此时,JIT无法用常量替换变量,因此无法应用某些优化,例如强度降低

特别是,这意味着JIT不会用乘法代替整数除法。(如果启用了这些优化,则当值是内联/常量传播后的编译时常数时,请参见为什么GCC在实现整数除法时为什么会在实现整数除法时使用一个奇数乘以乘法?)。表达式中的整数字面量也通过进行了优化,类似于此处甚至在OSR存根中也通过JITer进行了优化。)%gcc -O0

但是,如果您多次运行相同的方法,则第二次及以后的运行将执行常规(非OSR)代码,该代码已完全优化。这是证明该理论的基准使用JMH基准):

@State(Scope.Benchmark)
public class Div {

    @Benchmark
    public void divConst(Blackhole blackhole) {
        long startNum = 0;
        long stopNum = 100000000L;

        for (long i = startNum; i <= stopNum; i++) {
            if (i % 50000 == 0) {
                blackhole.consume(i);
            }
        }
    }

    @Benchmark
    public void divVar(Blackhole blackhole) {
        long startNum = 0;
        long stopNum = 100000000L;
        long progressCheck = 50000;

        for (long i = startNum; i <= stopNum; i++) {
            if (i % progressCheck == 0) {
                blackhole.consume(i);
            }
        }
    }
}

结果:

# Benchmark: bench.Div.divConst

# Run progress: 0,00% complete, ETA 00:00:16
# Fork: 1 of 1
# Warmup Iteration   1: 126,967 ms/op
# Warmup Iteration   2: 105,660 ms/op
# Warmup Iteration   3: 106,205 ms/op
Iteration   1: 105,620 ms/op
Iteration   2: 105,789 ms/op
Iteration   3: 105,915 ms/op
Iteration   4: 105,629 ms/op
Iteration   5: 105,632 ms/op


# Benchmark: bench.Div.divVar

# Run progress: 50,00% complete, ETA 00:00:09
# Fork: 1 of 1
# Warmup Iteration   1: 844,708 ms/op          <-- much slower!
# Warmup Iteration   2: 105,893 ms/op          <-- as fast as divConst
# Warmup Iteration   3: 105,601 ms/op
Iteration   1: 105,570 ms/op
Iteration   2: 105,475 ms/op
Iteration   3: 105,702 ms/op
Iteration   4: 105,535 ms/op
Iteration   5: 105,766 ms/op

divVar由于OSR存根的编译效率很低,因此的第一个迭代确实要慢得多。但是,从一开始就重新运行该方法,便会执行新的不受限制的版本,该版本会利用所有可用的编译器优化。


5
我对此毫不犹豫地投票。一方面,这听起来像是一种精心设计的方式:“您搞砸了基准,阅读了一些有关JIT的内容”。另一方面,我想知道为什么您似乎如此确定OSR是这里的主要相关问题。我的意思是,做一个(微)基准涉及System.out.println将几乎必然产生垃圾的结果,事实上,这两个版本是一样快没有做任何事情OSR在特定的,据我可以告诉..
Marco13

2
(我很好奇,喜欢理解这一点。希望这些注释不会打扰,以后可以将其删除,但是1:)链接有点可疑-空循环也可以完全优化掉。第二个更类似于那个。但是同样,不清楚为什么将差异专门归因于OSR 。我只是说:在某种程度上,该方法是JITed,并且变得更快。据我了解,OSR只会使最终的优化代码的使用(大致)被“推迟到下一个优化过程”。(续...)
Marco13

1
(续:)除非您专门分析热点日志,否则无法说出差异是由于比较JITed和非JITed代码还是比较JITed和OSR存根代码引起的。而且,您当然不能肯定地说出该问题何时不包含实际代码或完整的JMH基准。因此,对我来说,差异是由OSR声音引起的,与说一般是由JIT引起的差异相比,它是不恰当的(和“不合理的”)。(没有冒犯-我只是想知道...)
Marco13

4
@ Marco13有一个简单的试探法:没有JIT的活动,每个%操作的权重都将相同,因为只有在优化程序完成实际工作的情况下,优化的执行才可能。因此,一个循环变体比另一个循环变体快得多的事实证明了优化器的存在,并进一步证明了它未能将一个循环与另一个循环以相同的程度进行优化(在同一方法中!)。由于此答案证明了在同一个程度上优化两个回路的能力,因此必定有一些阻碍优化的因素。那就是OSR在所有案例中的99.9%
Holger

4
@ Marco13这是基于HotSpot Runtime的知识以及之前分析类似问题的经验而得出的“有根据的猜测”。这样长的循环很难用OSR以外的其他方式进行编译,尤其是在简单的手工基准测试中。现在,当OP发布完整的代码后,我只能通过使用运行代码再次确认推理-XX:+PrintCompilation -XX:+TraceNMethodInstalls
apangin

42

@phuclv comment的后续中,我检查了JIT 1生成的代码,结果如下:

对于variable % 5000(除以常数):

mov     rax,29f16b11c6d1e109h
imul    rbx
mov     r10,rbx
sar     r10,3fh
sar     rdx,0dh
sub     rdx,r10
imul    r10,rdx,0c350h    ; <-- imul
mov     r11,rbx
sub     r11,r10
test    r11,r11
jne     1d707ad14a0h

variable % variable

mov     rax,r14
mov     rdx,8000000000000000h
cmp     rax,rdx
jne     22ccce218edh
xor     edx,edx
cmp     rbx,0ffffffffffffffffh
je      22ccce218f2h
cqo
idiv    rax,rbx           ; <-- idiv
test    rdx,rdx
jne     22ccce218c0h

由于除法总是比乘法花费更长的时间,因此最后一个代码片段的性能较差。

Java版本:

java version "11" 2018-09-25
Java(TM) SE Runtime Environment 18.9 (build 11+28)
Java HotSpot(TM) 64-Bit Server VM 18.9 (build 11+28, mixed mode)

1-使用的VM选项: -XX:+UnlockDiagnosticVMOptions -XX:CompileCommand=print,src/java/Main.main


14
对于x86_64,给定一个“较慢”的数量级:imul是3个周期,idiv在30到90个周期之间。因此,整数除法比整数乘法慢10到30倍。
Matthieu M.

2
您能解释一下所有这些对有兴趣但不讲汇编语言的读者的含义吗?
Nico Haase

7
@NicoHaase这两个注释行是唯一重要的行。在第一部分中,代码执行整数乘法,而在第二部分中,代码执行整数除法。如果考虑手工进行乘法和除法,则乘法时通常是一堆小乘法,然后是一大组加法,但除法是小除法,小乘法,减法和重复。除法很慢,因为您实际上是在做一堆乘法。
MBraedley

4
@MBraedley感谢您的输入,但是这样的解释应该添加到答案本身,而不是隐藏在评论部分
Nico Haase 19'Jan

6
@MBraedley:更重要的是,现代CPU中的乘法运算速度很快,因为部分乘积是独立的,因此可以分开计算,而除法的每个阶段都取决于前面的阶段。
超级猫

26

正如其他人指出的那样,一般的模运算需要进行除法。在某些情况下,除法可以用乘法代替(由编译器)。但是与加/减相比,两者都可能较慢。因此,可以通过以下方式获得最佳性能:

long progressCheck = 50000;

long counter = progressCheck;

for (long i = startNum; i <= stopNum; i++){
    if (--counter == 0) {
        System.out.println(i);
        counter = progressCheck;
    }
}

(作为次要的优化尝试,我们在此处使用递减前递减计数器,因为在许多体系结构上,与0算术运算之后立即进行比较,其成本为0个指令/ CPU周期,因为在先运算中已经正确设置了ALU的标志。但是,即使您编写,编译器也会自动进行优化if (counter++ == 50000) { ... counter = 0; }。)

请注意,通常您实际上并不需要/不需要模数,因为您知道循环计数器(i)或仅增加1的值,并且您实际上并不关心模数将为您提供的实际余数,请看如果递增一计数器达到某个值。

另一个“窍门”是使用2的幂/限制,例如progressCheck = 1024;。模数的2的幂可以通过按位快速计算and,即if ( (i & (1024-1)) == 0 ) {...}。这也应该非常快,并且在某些架构上可能会胜过counter上面明确描述的。


3
一个聪明的编译器会在这里反转循环。或者,您可以在源代码中执行此操作。该if()主体成为一个外循环主体,而外部的东西if()成为一个运行min(progressCheck, stopNum-i)迭代的内循环主体。因此,从一开始,并且每次counter到达0时,您long next_stop = i + min(progressCheck, stopNum-i);都要进行for(; i< next_stop; i++) {}循环设置。在这种情况下,内部循环为空,希望可以完全优化,您可以在源代码中进行操作,并使JITer更加容易,将循环减少到i + = 50k。
彼得·科德斯

2
但是可以,总的来说,对于fizzbuzz / progresscheck类型的东西,倒数计数器是一种有效的技术。
彼得·科德斯

我添加了我的问题,并进行了增量,它--counter与我的增量版本一样快,但是代码更少。它也应该比它低1,我很好奇是否应该counter--获取您想要的确切数字,并不是说有很大的不同
Robert Cotterman

@PeterCordes一个聪明的编译器只打印数字,根本没有循环。(我认为,也许只是10年前,有些琐碎的基准测试才开始以这种方式失败。)
彼得-恢复莫妮卡(Monica)

2
@RobertCotterman是的,相距--counter一。counter--会给您确切的progressCheck迭代次数(progressCheck = 50001;当然也可以设置)。
JimmyB

4

看到上述代码的性能,我也感到惊讶。这完全取决于编译器根据声明的变量执行程序所花费的时间。在第二个示例中:

for (long i = startNum; i <= stopNum; i++) {
    if (i % progressCheck == 0) {
        System.out.println(i)
    }
}

您正在两个变量之间执行模运算。在此,编译器必须在每次迭代后每次检查这些变量的值,stopNumprogressCheck转到这些变量所在的特定内存块,因为它是变量,并且其值可能会更改。

这就是为什么在每次迭代之后,编译器都会转到内存位置以检查变量的最新值。因此,在编译时,编译器无法创建有效的字节码。

在第一个代码示例中,您将在变量和常量数值之间执行模运算符,该常量在执行过程中不会更改,并且编译器无需从内存位置检查该数值的值。这就是为什么编译器能够创建有效的字节码的原因。如果你声明progressCheckfinal或作为一个final static变量,然后在运行时/编译时编译器知道的时候,这是一个最终的变量,它的值不会改变,然后编译器替换progressCheck50000的代码:

for (long i = startNum; i <= stopNum; i++) {
    if (i % 50000== 0) {
        System.out.println(i)
    }
}

现在您可以看到该代码也看起来像第一个(有效的)代码示例。第一个代码的性能以及我们上面提到的两个代码都将有效地工作。任一代码示例的执行时间都不会有太大差异。


1
尽管我进行了1万亿次操作,但还是有很大的不同,因此,超过1万亿次操作可以节省89%的时间来执行“高效”的代码。请注意,如果您只做几千次,却说的这么细微的差别,那可能没什么大不了的。我的意思是超过1000次操作将为您节省7秒的百万分之一。
罗伯特·科特曼

1
@Bishal Dubey“两个代码的执行时间不会有太大差异。” 你读问题了吗?
Grant Foster

“这就是为什么在每次迭代之后,编译器都会前往内存位置检查变量的最新值”-除非声明volatile了变量,否则“编译器” 不会一次又一次地从RAM中读取其值。
JimmyB
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.