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

时间:2019-01-28 16:01:08

标签: java performance

我是java的新手,并且昨晚正在运行一些代码,这确实让我感到困扰。我当时正在构建一个简单的程序,以在for循环中显示每个X输出,当我将模数用作variable % variablevariable % 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毫秒
  • 短转换= 679毫秒

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

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

最后一个有趣的注解,在“最终声明的变量”上使用65536(不是一个漂亮的数字)的模数,其速度是(慢于)固定值的一半。之前在哪里以接近相同的速度进行基准测试。

4 个答案:

答案 0 :(得分:138)

您正在测量OSR (on-stack replacement)存根。

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

OSR存根不像常规方法那样优化,因为它们需要与解释的帧兼容的帧布局。我已经在以下答案中证明了这一点:123

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

特别是,这意味着JIT不会将整数除替换为乘法。 (如果提前启用编译器,则值是内联/常数传播后的编译时常数(如果启用了这些优化),请参见Why does GCC use multiplication by a strange number in implementing integer division?,了解提前编译器提供的asm技巧。{{ 1}}表达式也可以通过%进行优化,类似于此处甚至在OSR存根中也可以通过JITer对其进行优化。)

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

gcc -O0

结果:

@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 的第一次迭代确实要慢得多,因为OSR存根的编译效率很低。但是,只要方法从头开始重新运行,就会执行新的不受限制的版本,该版本会利用所有可用的编译器优化。

答案 1 :(得分: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

答案 2 :(得分: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的东西,而您实际上并不关心实际的余数模数会给你,只要看看递增1计数器是否达到某个值即可。

另一个技巧是使用2的幂/限值,例如progressCheck = 1024;。可以通过按位andif ( (i & (1024-1)) == 0 ) {...}快速计算出2的幂。这也应该非常快,并且在某些架构上可能会胜过上面明确的counter

答案 3 :(得分:3)

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

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

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

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

在第一个代码示例中,您将在变量和常量数值之间执行模运算符,该常量在执行期间不会发生变化,并且编译器无需从内存位置检查该数值的值。这就是为什么编译器能够创建有效的字节码的原因。如果将progressCheck声明为finalfinal static变量,则在运行时/编译时编译器会知道这是一个最终变量,其值不会改变然后编译器在代码中将progressCheck替换为50000

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

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