【问题标题】:For loop performance: counters with same value vs. different values对于循环性能:具有相同值与不同值的计数器
【发布时间】:2020-09-07 21:22:15
【问题描述】:

我有一个带有 2 个计数器的循环:i 和 j。如果它们具有相同的值 - 迭代比它们的值不同时更快:

Benchmark                     Mode  Cnt       Score      Error  Units
FloatsArrayBenchmark.times   thrpt   20  341805.800 ± 1623.320  ops/s
FloatsArrayBenchmark.times2  thrpt   20  198764.909 ± 1608.387  ops/s

Java 字节码是相同的,这意味着它与一些较低级别的优化有关。有人可以解释为什么会这样吗?这是基准:

import org.openjdk.jmh.annotations.*;

public class FloatsArrayBenchmark {
    public static void main(String[] args) throws Exception {
        org.openjdk.jmh.Main.main(new String[]{FloatsArrayBenchmark.class.getSimpleName()});
    }

    @Benchmark @Fork(value = 1, warmups = 0)
    public void times(Data data) {
        float[] result = new float[10000];;
        for (int i = 0, j=0; i < 9_999; i++,j++)
            result[j] = data.floats[i] * 10;
    }
    @Benchmark @Fork(value = 1, warmups = 0)
    public void times2(Data data) {
        float[] result = new float[10000];
        for (int i = 0,j=1; i < 9_999; i++,j++)
            result[j] = data.floats[i] * 10;
    }

    @State(Scope.Benchmark)
    public static class Data {
        private final float[] floats = new float[10000];
    }
}

环境:

  • MacOS,试过Java8、Java11、Java14
  • 2.4 GHz 四核 Intel Core i5

【问题讨论】:

  • 如果 src 和 dst 数组相对于 4k 页面边界具有相同的对齐方式,则可能是 4k 别名(以后加载的错误依赖性)存储在您正在阅读的位置之前的 1 个索引。对于最简单的情况(相同的索引),Java8 可能太旧了,无法使用 SIMD 进行自动矢量化。
  • 我认为第一个比较慢,因为双分号;; ...... NOT ...... ????
  • @PeterCordes,Java11 - 结果相同。下载 Java14 试用。不确定别名 - 将不得不阅读。
  • @Andreas,这是我的第一个猜测;)已编辑。
  • 此时,您必须进行程序集转储。正如彼得建议的那样,我的猜测可能与缓存边界有关,但如果您真的想知道,请深入挖掘。

标签: java for-loop optimization micro-optimization


【解决方案1】:

在第一个(更快的)版本中,i 总是(有效地)与j 具有相同的值,所以它:

public void times(Data data) {
    float[] result = new float[10000];;
    for (int i=0, j=0; i < 9_999; i++,j++)
        result[j] = data.floats[i] * 10;
}

可以在没有j 的情况下重写,效果相同:

public void times(Data data) {
    float[] result = new float[10000];;
    for (int i = 0; i < 9_999; i++)
        result[i] = data.floats[i] * 10;
}

很可能编译器认识到j 是多余的并消除了它,导致执行的++ 操作数量减少了一半,占所有算术操作的1/3。这与时间是一致的:第二个版本每次迭代的时间要长 70%。 70% 约为 50%,这是 3:2 操作比例的预期结果。

【讨论】:

  • 一个稍微聪明一点的编译器仍然可以对它们进行 CSE 并使用一个恒定的 4 字节偏移量作为result[i+1] 寻址模式的一部分,或者在增量之前进行加载并在增量之后进行存储。例如提前编译 C 时,clang 对此没有任何问题 (godbolt.org/z/aeKMrG)。但如果 HotSpot 的 JIT 错过了这个简单的优化,我也不会感到惊讶。
  • 第一个近似值,在没有展开的小循环中的一条额外指令可以将运行时间增加那么多,在现代 CPU 前端(如 Haswell 及更高版本)上没有如此强大的倍数-of-front-end-width 效果(Is performance reduced when executing loops whose uop count is not a multiple of processor width? - 在 Sandybridge 上是,在 HSW 上没有那么多)。展开是没有意义的,除非编译器真的不擅长展开并且每个整数递增而不是使用寻址模式偏移量。
  • 可能是......虽然不确定计算是否加起来 - 循环结束还有一个条件。 Java 还会检查我们是否超出了数组大小,我敢打赌 CPU 必须执行大量额外的操作。
猜你喜欢
  • 1970-01-01
  • 2015-06-30
  • 2014-10-09
  • 2021-02-23
  • 1970-01-01
  • 2022-07-03
  • 2012-08-13
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多