模运算符和按位与的性能比较

4

我正在努力确定一个32位整数是偶数还是奇数。我设置了两种方法:

取模(%)方法

int r = (i % 2);

按位(&)运算方法
int r = (i & 0x1);

两种方法都能成功运行。因此,我对每行代码进行了15000次性能测试。

结果:

取模(%)方法 (源代码)

平均值141.5801887ns | 标准偏差270.0700275ns

位运算(&)方法 (源代码)

平均值141.2504ns | 标准偏差193.6351007ns

问题:

为什么位运算(&)比除法(%)更稳定?

JVM是否根据这里的内容使用AND(&)优化模数(%)操作?


1
你的两个基准测试对我来说几乎完全相同,只是标准差略有不同。但是,有可能你甚至没有以统计学上有意义的方式设置这两个测试。 - Tim Biegeleisen
第一个链接应该是 https://github.com/kflau/modulo-division-vs-bitwise-benchmark/blob/master/src/test/java/com/aei/bit/modulo/benchmark/ModulusTest.java。 - luk2302
2
你完全错了。你不能使用nanoTime()来测量单个操作的执行时间,因为它通常具有数百纳秒甚至更多的粒度(取决于您的操作系统)。使用JMH并检查汇编代码。 - Andrey Cheboksarov
3个回答

12

让我们尝试使用JMH进行复现。

@Benchmark
@Measurement(timeUnit = TimeUnit.NANOSECONDS)
@BenchmarkMode(Mode.AverageTime)
public int first() throws IOException {
    return i % 2;
}

@Benchmark
@Measurement(timeUnit = TimeUnit.NANOSECONDS)
@BenchmarkMode(Mode.AverageTime)
public int second() throws IOException {
    return i & 0x1;
}

好的,这是可重现的。第一个比第二个稍微慢一些。现在让我们弄清楚为什么。使用-prof perfnorm运行它:

Okay, it is reproducible. The first is slightly slower than the second. Now let's figure out why. Run it with -prof perfnorm:
Benchmark                                 Mode  Cnt   Score    Error  Units
MyBenchmark.first                         avgt   50   2.674 ±  0.028  ns/op
MyBenchmark.first:CPI                     avgt   10   0.301 ±  0.002   #/op
MyBenchmark.first:L1-dcache-load-misses   avgt   10   0.001 ±  0.001   #/op
MyBenchmark.first:L1-dcache-loads         avgt   10  11.011 ±  0.146   #/op
MyBenchmark.first:L1-dcache-stores        avgt   10   3.011 ±  0.034   #/op
MyBenchmark.first:L1-icache-load-misses   avgt   1010⁻³            #/op
MyBenchmark.first:LLC-load-misses         avgt   1010⁻⁴            #/op
MyBenchmark.first:LLC-loads               avgt   1010⁻⁴            #/op
MyBenchmark.first:LLC-store-misses        avgt   1010⁻⁵            #/op
MyBenchmark.first:LLC-stores              avgt   1010⁻⁴            #/op
MyBenchmark.first:branch-misses           avgt   1010⁻⁴            #/op
MyBenchmark.first:branches                avgt   10   4.006 ±  0.054   #/op
MyBenchmark.first:cycles                  avgt   10   9.322 ±  0.113   #/op
MyBenchmark.first:dTLB-load-misses        avgt   1010⁻⁴            #/op
MyBenchmark.first:dTLB-loads              avgt   10  10.939 ±  0.175   #/op
MyBenchmark.first:dTLB-store-misses       avgt   1010⁻⁵            #/op
MyBenchmark.first:dTLB-stores             avgt   10   2.991 ±  0.045   #/op
MyBenchmark.first:iTLB-load-misses        avgt   1010⁻⁵            #/op
MyBenchmark.first:iTLB-loads              avgt   1010⁻⁴            #/op
MyBenchmark.first:instructions            avgt   10  30.991 ±  0.427   #/op
MyBenchmark.second                        avgt   50   2.263 ±  0.015  ns/op
MyBenchmark.second:CPI                    avgt   10   0.320 ±  0.001   #/op
MyBenchmark.second:L1-dcache-load-misses  avgt   10   0.001 ±  0.001   #/op
MyBenchmark.second:L1-dcache-loads        avgt   10  11.045 ±  0.152   #/op
MyBenchmark.second:L1-dcache-stores       avgt   10   3.014 ±  0.032   #/op
MyBenchmark.second:L1-icache-load-misses  avgt   1010⁻³            #/op
MyBenchmark.second:LLC-load-misses        avgt   1010⁻⁴            #/op
MyBenchmark.second:LLC-loads              avgt   1010⁻⁴            #/op
MyBenchmark.second:LLC-store-misses       avgt   1010⁻⁵            #/op
MyBenchmark.second:LLC-stores             avgt   1010⁻⁴            #/op
MyBenchmark.second:branch-misses          avgt   1010⁻⁴            #/op
MyBenchmark.second:branches               avgt   10   4.014 ±  0.045   #/op
MyBenchmark.second:cycles                 avgt   10   8.024 ±  0.098   #/op
MyBenchmark.second:dTLB-load-misses       avgt   1010⁻⁵            #/op
MyBenchmark.second:dTLB-loads             avgt   10  10.989 ±  0.161   #/op
MyBenchmark.second:dTLB-store-misses      avgt   1010⁻⁶            #/op
MyBenchmark.second:dTLB-stores            avgt   10   3.004 ±  0.042   #/op
MyBenchmark.second:iTLB-load-misses       avgt   1010⁻⁵            #/op
MyBenchmark.second:iTLB-loads             avgt   1010⁻⁵            #/op
MyBenchmark.second:instructions           avgt   10  25.076 ±  0.296   #/op

注意循环和指令的区别。现在这已经很明显了。第一个关心符号,但第二个不关心(只进行按位与操作)。为确保这是原因,请查看汇编片段:

first:

0x00007f91111f8355: mov     0xc(%r10),%r11d   ;*getfield i
0x00007f91111f8359: mov     %r11d,%edx
0x00007f91111f835c: and     $0x1,%edx
0x00007f91111f835f: mov     %edx,%r10d
0x00007f6bd120a6e2: neg     %r10d
0x00007f6bd120a6e5: test    %r11d,%r11d
0x00007f6bd120a6e8: cmovl   %r10d,%edx       

第二:

0x00007ff36cbda580: mov     $0x1,%edx
0x00007ff36cbda585: mov     0x40(%rsp),%r10
0x00007ff36cbda58a: and     0xc(%r10),%edx  

4
我在这里缺少一个解释,例如“模数是一种非常慢的操作,但JVM将i%2优化为`i> 0?i&1:-(i&1)”。 - maaartinus
1
@maaartinus 如果 i 是 2 的幂,则为真。否则,它将编译为一些更重的位操作(令人惊讶的是,不是我所期望的简单的 idiv)。 - St.Antario

1

150纳秒的执行时间约为500个时钟周期。我认为从来没有一个处理器会以如此低效的方式检查一位 :-).

问题在于你的测试框架存在很多缺陷,特别是:

  • 在启动时钟之前,你没有尝试触发JIT编译
  • System.nanotime()不能保证具有纳秒精度
  • System.nanotime()的调用成本比你想要测量的代码要高得多

请参见 如何编写正确的Java微基准测试? 以获取更完整的注意事项清单。

这是一个更好的基准测试:

public abstract class Benchmark {

    final String name;

    public Benchmark(String name) {
        this.name = name;
    }

    @Override
    public String toString() {
        return name + "\t" + time() + " ns / iteration";
    }

    private BigDecimal time() {
        try {
            // automatically detect a reasonable iteration count (and trigger just in time compilation of the code under test)
            int iterations;
            long duration = 0;
            for (iterations = 1; iterations < 1_000_000_000 && duration < 1_000_000_000; iterations *= 2) {
                long start = System.nanoTime();
                run(iterations);
                duration = System.nanoTime() - start;
                cleanup();
            }
            return new BigDecimal((duration) * 1000 / iterations).movePointLeft(3);
        } catch (Throwable e) {
            throw new RuntimeException(e);
        }
    }

    /**
     * Executes the code under test.
     * @param iterations
     *            number of iterations to perform
     * @return any value that requires the entire code to be executed (to
     *         prevent dead code elimination by the just in time compiler)
     * @throws Throwable
     *             if the test could not complete successfully
     */
    protected abstract Object run(int iterations) throws Throwable;

    /**
     * Cleans up after a run, setting the stage for the next.
     */
    protected void cleanup() {
        // do nothing
    }

    public static void main(String[] args) throws Exception {
        System.out.println(new Benchmark("%") {
            @Override
            protected Object run(int iterations) throws Throwable {
                int sum = 0;
                for (int i = 0; i < iterations; i++) {
                    sum += i % 2;
                }
                return sum; 
            }
        });
        System.out.println(new Benchmark("&") {
            @Override
            protected Object run(int iterations) throws Throwable {
                int sum = 0;
                for (int i = 0; i < iterations; i++) {
                    sum += i & 1;
                }
                return sum;
            }
        });
    }
}

在我的电脑上,它会打印出:
%   0.375 ns / iteration
&   0.139 ns / iteration

所以,预期的区别在几个时钟周期的数量级上。也就是说,在这个特定的硬件上,这个JIT对& 1进行了稍微更好的优化,但差异非常小,极不可能对程序的性能产生可测量(更不用说显著)的影响。

0
这两个操作对应于不同的JVM处理器指令:
irem     // int remainder (%)
iand     // bitwise and (&)

我在某处读到,irem通常由JVM实现,而硬件上可用的是iandOracle如下解释了这两个指令:

iand

通过对value1和value2进行按位AND(合并),计算出一个int结果。

irem

int结果为value1-(value1 / value2)* value2。

我认为假设iand会导致更少的CPU周期是合理的。


大多数CPU也可以计算余数,但速度较慢,JVM会尽可能地将其优化为AND操作。 - maaartinus
@maaartinus 这个优化是在什么时候进行的? - steffen
2
实际上,所有的优化都是在运行时进行的。javac(或者ecj或其他编译器)并不关心这些。JVM 才是关键。许多优化会导致代码膨胀,更好的方法是集中精力处理热点代码(因此他们称之为热点编译器)。此外,许多优化都基于一些假设,这些假设可能会在以后被否定,JVM 必须取消优化。因此,在字节码中看不到任何类似的东西。 - maaartinus

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接