【问题标题】:Why is if (variable1 % variable2 == 0) inefficient?为什么 if (variable1 % variable2 == 0) 效率低下?
【发布时间】:2019-06-21 16:23:22
【问题描述】:

我是 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 比我的效率更高或其他情况,您可能需要增加 stopNum 或减少 progressCheck

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

编辑: 我没想到我的问题如此受欢迎,我感谢所有的答案。我确实在每一半所花费的时间上执行了一个基准测试,效率低下的代码花费了相当长的时间,1/4 秒与 10 秒的给予或接受。当然他们正在使用 println,但他们都在做相同的数量,所以我不认为这会扭曲它,特别是因为差异是可重复的。至于答案,由于我是 Java 新手,我现在让投票决定哪个答案是最好的。我会在星期三之前挑一个。

编辑2: 今晚我将进行另一次测试,它不是模数,而是增加一个变量,当它到达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 毫秒

不足为奇,由于缺少除法,Short Conversion 比“快速”方式快 23%。这很有趣。如果您需要每 256 次(或大约在那里)显示或比较一些东西,您可以这样做,并使用

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

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

【问题讨论】:

  • 我实际上得到了相同的结果。在我的机器上,第一个循环运行大约 1.5 秒,第二个循环运行大约 9 秒。如果我在progressCheck 变量前面添加final,则两者再次以相同的速度运行。这让我相信当编译器或 JIT 知道 progressCheck 是常量时,它会设法优化循环。
  • 除以常数can be easily converted to a multiplication by the multiplicative inverse。除以变量不能。在 x86 上,32 位除法比 64 位除法更快
  • @phuclv 注意 32 位除法在这里不是问题,在这两种情况下都是 64 位余数运算
  • @RobertCotterman 如果将变量声明为 final,编译器会创建与使用常量 (eclipse/Java 11) 相同的字节码((尽管为变量使用了一个内存槽))

标签: java performance


【解决方案1】:

您正在测量 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 技巧,当值是内联/常量传播后的编译时常量时,如果启用了这些优化。% 表达式中的整数文字也得到了gcc -O0 的优化,类似于这里它由 JITer 优化,即使在 OSR 存根中。)

但是,如果您多次运行相同的方法,则第二次和后续运行将执行已完全优化的常规(非 OSR)代码。这是证明该理论的基准(benchmarked using 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 存根效率低下。但只要方法从头开始重新运行,就会执行新的无约束版本,该版本利用了所有可用的编译器优化。

【讨论】:

  • 对此我犹豫不决。一方面,这听起来像是在说“你搞砸了你的基准,阅读一些关于 JIT 的东西”。另一方面,我想知道为什么您似乎如此确定 OSR 是这里的主要相关点。我的意思是,做一个涉及System.out.println 的(微)基准测试将几乎必然产生垃圾结果,而且两个版本同样快的事实并不需要对 OSR 做任何事情,尤其是,据我所知..
  • @Marco13 答案是指类似的问题(12),我已经证明原因是 OSR 编译。这也可以通过使用-XX:+PrintCompilation 运行基准来验证。
  • (我很好奇并想了解这一点。我希望 cmets 不会打扰,以后可能会删除它们,但是:)链接 1 有点可疑 - 空循环也可以被完全优化掉。第二个更类似于那个。但同样,不清楚为什么您将差异归因于 OSR 具体。我只想说:在某些时候,该方法是 JITed 并且变得更快。据我了解,OSR 只会导致最终优化代码的使用(大致)是~“推迟到下一个优化阶段”。 (续...)
  • @Marco13 有一个简单的启发式方法:如果没有 JIT 的活动,每个 % 操作将具有相同的权重,因为优化执行只有在优化器进行实际工作时才有可能。因此,一个循环变体明显快于另一个这一事实证明了优化器的存在,并进一步证明了它未能将其中一个循环优化到与另一个循环相同的程度(在相同的方法中!)。由于这个答案证明了将两个循环优化到相同程度的能力,因此一定有一些东西阻碍了优化。这就是所有案例中 99.9% 的 OSR
  • @Marco13 这是基于 HotSpot Runtime 的知识和之前分析类似问题的经验的“有根据的猜测”。这么长的循环很难用 OSR 以外的方式编译,尤其是在简单的手工基准测试中。现在,当 OP 发布了完整的代码后,我只能通过 -XX:+PrintCompilation -XX:+TraceNMethodInstalls 运行代码来再次确认推理。
【解决方案2】:

跟进@phuclvcomment,查看了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

因为除法总是比乘法花费更长的时间,所以最后的代码 sn-p 性能较差。

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 - 使用的虚拟机选项:-XX:+UnlockDiagnosticVMOptions -XX:CompileCommand=print,src/java/Main.main

【讨论】:

  • 给出“较慢”的数量级,对于 x86_64:imul 是 3 个周期,idiv 在 30 到 90 个周期之间。所以整数除法比整数乘法慢 10 倍到 30 倍。
  • 您能否解释一下所有这些对于感兴趣但不会说汇编语言的读者意味着什么?
  • @NicoHaase 两个注释行是唯一重要的行。在第一部分,代码执行整数乘法,而第二部分代码执行整数除法。如果你考虑手动进行乘法和除法,当你乘法时,你通常会做一堆小的乘法,然后是一组大的加法,但除法是一个小的除法,一个小的乘法,一个减法,然后重复。除法很慢,因为你实际上是在做一堆乘法。
  • @MBraedley 感谢您的输入,但此类解释应添加到答案本身,而不是隐藏在评论部分
  • @MBraedley:更重要的是,现代 CPU 中的乘法速度很快,因为部分乘积是独立的,因此可以单独计算,而除法的每个阶段都依赖于前面的阶段。
【解决方案3】:

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

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 的东西,并且您真的不关心模数的实际余数会给你,看看递增一计数器是否达到某个值。

另一个“技巧”是使用二次幂值/限制,例如progressCheck = 1024;。模数 2 的幂可以通过逐位 and 快速计算,即 if ( (i &amp; (1024-1)) == 0 ) {...}。这也应该很快,并且在某些架构上的性能可能优于上面的显式counter

【讨论】:

  • 智能编译器会在此处反转循环。或者你可以在源代码中做到这一点。 if() 主体成为外循环主体,if() 之外的内容成为运行 min(progressCheck, stopNum-i) 迭代的内循环主体。因此,在开始时,每次counter 达到 0,您都执行long next_stop = i + min(progressCheck, stopNum-i); 来设置for(; i&lt; next_stop; i++) {} 循环。在这种情况下,内部循环是空的,应该希望完全优化掉,你可以在源代码中这样做,让 JITer 更容易,将你的循环减少到 i+=50k。
  • 但是是的,一般来说,向下计数器对于 fizzbuzz / progresscheck 类型的东西来说是一种很好的有效技术。
  • 我添加到我的问题中,并进行了增量,--counter 与我的增量版本一样快,但代码更少。它也比应有的低 1,我很好奇如果它应该是counter-- 来获得你想要的确切数字,并不是说差别很大
  • @PeterCordes 一个 smart 编译器只会打印数字,根本没有循环。 (我认为可能在 10 年前,一些稍微更琐碎的基准测试开始以这种方式失败。)
  • @RobertCotterman 是的,--counter 减一。 counter-- 将为您提供准确的 progressCheck 迭代次数(当然,您也可以设置 progressCheck = 50001;)。
【解决方案4】:

看到上述代码的性能,我也很惊讶。这都是关于编译器根据声明的变量执行程序所花费的时间。在第二个(低效)示例中:

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

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

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

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

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

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

【讨论】:

  • 有一个巨大的差异,尽管我做了一万亿次操作,所以超过 1 万亿次操作它节省了 89% 的时间来执行“高效”代码。请注意,如果您只做了几千次,那么差异很小,可能没什么大不了的。我的意思是超过 1000 次操作可以为您节省 7 秒的百万分之一。
  • @Bishal Dubey “两个代码的执行时间不会有太大差异。”你读过这个问题吗?
  • “这就是为什么每次迭代后编译器都会去内存位置检查变量的最新值” - 除非变量被声明为volatile,否则“编译器”将不会 一遍又一遍地从 RAM 中读取它的值。
猜你喜欢
  • 2014-10-19
  • 1970-01-01
  • 2019-03-05
  • 2017-08-05
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2023-04-04
  • 1970-01-01
相关资源
最近更新 更多