【问题标题】:Java: manually-unrolled loop is still faster than the original loop. Why?Java:手动展开的循环仍然比原始循环快。为什么?
【发布时间】:2020-03-18 14:43:56
【问题描述】:

考虑以下两个长度为 2 的数组的 sn-ps 代码:

boolean isOK(int i) {
    for (int j = 0; j < filters.length; ++j) {
        if (!filters[j].isOK(i)) {
            return false;
        }
    }
    return true;
}

boolean isOK(int i) {
     return filters[0].isOK(i) && filters[1].isOK(i);
}

我会假设这两个部分的性能在充分热身后应该是相似的。
我已经使用 JMH 微基准测试框架检查了这一点,如所述,例如herehere 并观察到第二个 sn-p 快 10% 以上。

问题:为什么 Java 没有使用基本的循环展开技术优化我的第一个 sn-p?
我特别想了解以下内容:

  1. 我可以轻松地生成一个代码,该代码对于 2 个过滤器的情况是最佳的,并且在其他数量的过滤器的情况下仍然可以工作(想象一个简单的构建器):
    return (filters.length) == 2 ? new FilterChain2(filters) : new FilterChain1(filters)。 JITC 可以这样做吗?如果不能,为什么?
  2. JITC 能否检测到“filters.length==2”是最常见的情况,并在预热后生成最适合这种情况的代码?这应该几乎与手动展开的版本一样最佳。
  3. JITC 能否检测到某个特定实例被非常频繁地使用,然后为该特定实例生成一个代码(它知道过滤器的数量始终为 2 )?
    更新: 得到的答案是 JITC 仅在班级级别上有效。好的,知道了。

理想情况下,我希望得到对 JITC 工作原理有深入了解的人的回答。

基准运行详情:

  • 在最新版本的 Java 8 OpenJDK 和 Oracle HotSpot 上试过,结果差不多
  • 使用的 Java 标志:-Xmx4g -Xms4g -server -Xbatch -XX:CICompilerCount=2(没有花哨的标志也得到了类似的结果)
  • 顺便说一句,如果我简单地在一个循环中运行数十亿次(不是通过 JMH),我会得到类似的运行时间比率,即第二个 sn-p 显然总是更快

典型的基准测试输出:

基准 (filterIndex) 模式 Cnt 分数 错误单位
LoopUnrollingBenchmark.runBenchmark 0 平均 400 44.202 ± 0.224 ns/操作
LoopUnrollingBenchmark.runBenchmark 1 平均 400 38.347 ± 0.063 纳秒/操作

(第一行对应第一个sn-p,第二行-对应第二个。

完整的基准代码:

public class LoopUnrollingBenchmark {

    @State(Scope.Benchmark)
    public static class BenchmarkData {
        public Filter[] filters;
        @Param({"0", "1"})
        public int filterIndex;
        public int num;

        @Setup(Level.Invocation) //similar ratio with Level.TRIAL
        public void setUp() {
            filters = new Filter[]{new FilterChain1(), new FilterChain2()};
            num = new Random().nextInt();
        }
    }

    @Benchmark
    @Fork(warmups = 5, value = 20)
    @BenchmarkMode(Mode.AverageTime)
    @OutputTimeUnit(TimeUnit.NANOSECONDS)
    public int runBenchmark(BenchmarkData data) {
        Filter filter = data.filters[data.filterIndex];
        int sum = 0;
        int num = data.num;
        if (filter.isOK(num)) {
            ++sum;
        }
        if (filter.isOK(num + 1)) {
            ++sum;
        }
        if (filter.isOK(num - 1)) {
            ++sum;
        }
        if (filter.isOK(num * 2)) {
            ++sum;
        }
        if (filter.isOK(num * 3)) {
            ++sum;
        }
        if (filter.isOK(num * 5)) {
            ++sum;
        }
        return sum;
    }


    interface Filter {
        boolean isOK(int i);
    }

    static class Filter1 implements Filter {
        @Override
        public boolean isOK(int i) {
            return i % 3 == 1;
        }
    }

    static class Filter2 implements Filter {
        @Override
        public boolean isOK(int i) {
            return i % 7 == 3;
        }
    }

    static class FilterChain1 implements Filter {
        final Filter[] filters = createLeafFilters();

        @Override
        public boolean isOK(int i) {
            for (int j = 0; j < filters.length; ++j) {
                if (!filters[j].isOK(i)) {
                    return false;
                }
            }
            return true;
        }
    }

    static class FilterChain2 implements Filter {
        final Filter[] filters = createLeafFilters();

        @Override
        public boolean isOK(int i) {
            return filters[0].isOK(i) && filters[1].isOK(i);
        }
    }

    private static Filter[] createLeafFilters() {
        Filter[] filters = new Filter[2];
        filters[0] = new Filter1();
        filters[1] = new Filter2();
        return filters;
    }

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

【问题讨论】:

  • 编译器不能保证数组的长度是2。我不确定它是否会展开它,即使它可以。
  • @Setup(Level.Invocation) :不确定是否有帮助(请参阅 javadoc)。
  • 由于无法保证数组的长度始终为 2,因此这两种方法的作用不同。 JIT 怎么会允许自己将第一个变成第二个?
  • @Andreas 我建议您回答这个问题,但请详细说明为什么 JIT 在这种情况下无法展开,与另一个类似的情况相比,它可以
  • @Alexander JIT 可以看到创建后数组长度不能改变,因为字段是final,但是JIT没有看到所有实例 类将获得一个长度为 2 的数组。要看到这一点,它必须深入研究 createLeafFilters() 方法并深入分析代码以了解该数组始终是 2 长。为什么您认为 JIT 优化器会深入到您的代码中?

标签: java performance optimization jit


【解决方案1】:

所呈现的循环可能属于“未计数”循环类别,即无法在编译时或运行时确定迭代次数的循环。不仅因为@Andreas 关于数组大小的论点,还因为随机条件break(我写这篇文章时曾经在你的基准测试中)。

最先进的编译器不会积极地 优化它们,因为展开非计数循环通常涉及 也复制循环的退出条件,因此只会提高 运行时性能,如果后续编译器优化可以 优化展开的代码。请参阅此2017 paper,了解他们提出如何展开此类内容的建议的详细信息。

由此可见,您的假设并不认为您确实对循环进行了某种“手动展开”。您正在考虑将它作为一种基本的循环展开技术,将带有条件中断的数组上的迭代转换为&amp;&amp; 链式布尔表达式。我认为这是一个相当特殊的情况,并且会惊讶地发现热点优化器会即时进行复杂的重构。 Here 他们正在讨论它实际上可能会做什么,也许this reference 很有趣。

这将更接近当代展开的机制,并且可能仍远不及展开的机器代码的样子:

if (! filters[0].isOK(i))
{
   return false;
} 
if(! filters[1].isOK(i))
{
   return false;
}
return true;

您的结论是,因为一段代码比另一段代码运行得更快,所以循环没有展开。即使是这样,由于您正在比较不同的实现,您仍然可以看到运行时差异。

如果您想获得更多确定性,可以使用 jitwatch 分析器/可视化器来分析实际的 Jit 操作,包括机器代码 (github) (presentation slides)。如果最终有什么可看的,我会更相信自己的眼睛,而不是任何关于 JIT 可能会或可能不会做什么的意见,因为每个案例都有其具体情况。 Here 他们担心就 JIT 而言很难针对特定案例得出一般性陈述,并提供了一些有趣的链接。

由于您的目标是最短运行时间,如果您不想依赖希望进行循环展开,a &amp;&amp; b &amp;&amp; c ... 表单可能是最有效的表单,至少比目前提供的任何其他表单都更有效。但是你不能以通用的方式拥有它。使用java.util.Function 的函数组合,又会产生巨大的开销(每个函数都是一个类,每个调用都是一个需要调度的虚拟方法)。也许在这种情况下,颠覆语言级别并在运行时生成custom byte code 可能是有意义的。另一方面,&amp;&amp; 逻辑 requires branching 在字节码级别也可能等效于 if/return(如果没有开销也无法生成)。

【讨论】:

  • 只是一个小附录:JVM 世界中的计数循环是“运行”在 int i = ....; i &lt; ...; ++i 上的任何循环,任何 other 循环都不是。
【解决方案2】:

TL;DR 这里性能差异的主要原因与循环展开无关。而是类型推测内联缓存

展开策略

事实上,在 HotSpot 术语中,此类循环被视为计数,并且在某些情况下,JVM 可以展开它们。但不是你的情况。

HotSpot 有两种循环展开策略:1) 最大程度展开,即完全移除循环;或 2) 将几个连续的迭代粘合在一起。

只有在exact number of iterations is known 时才能进行最大展开。

  if (!cl->has_exact_trip_count()) {
    // Trip count is not exact.
    return false;
  }

但是,在您的情况下,该函数可能会在第一次迭代后提前返回。

可能会应用部分展开,但 following condition 会中断展开:

  // Don't unroll if the next round of unrolling would push us
  // over the expected trip count of the loop.  One is subtracted
  // from the expected trip count because the pre-loop normally
  // executes 1 iteration.
  if (UnrollLimitForProfileCheck > 0 &&
      cl->profile_trip_cnt() != COUNT_UNKNOWN &&
      future_unroll_ct        > UnrollLimitForProfileCheck &&
      (float)future_unroll_ct > cl->profile_trip_cnt() - 1.0) {
    return false;
  }

由于在您的情况下,预期行程计数小于 2,HotSpot 认为即使展开两次迭代也不值得。请注意,第一次迭代无论如何都会被提取到预循环中(loop peeling optimization),所以在这里展开确实不是很有用。

类型推测

在您的展开版本中,有两个不同的 invokeinterface 字节码。这些站点有两种不同的类型配置文件。第一个接收者总是Filter1,第二个接收者总是Filter2。因此,您基本上有两个单态调用站点,HotSpot 可以完美地内联这两个调用 - 所谓的“内联缓存”,在这种情况下具有 100% 的命中率。

通过循环,只有一个invokeinterface 字节码,并且只收集了一个类型配置文件。 HotSpot JVM 发现 filters[j].isOK()Filter1 接收器调用了 86% 次,Filter2 接收器调用了 14% 次。这将是一个双态调用。幸运的是,HotSpot 也可以推测内联双态调用。它使用条件分支内联两个目标。但是,在这种情况下,命中率最高为 86%,并且性能会受到架构级别相应的错误预测分支的影响。

如果您有 3 个或更多不同的过滤器,情况会更糟。在这种情况下,isOK() 将是 HotSpot 根本无法内联的超态调用。因此,编译后的代码将包含一个真正的接口调用,这会对性能产生更大的影响。

更多关于投机内联的文章The Black Magic of (Java) Method Dispatch

结论

为了内联虚拟/接口调用,HotSpot JVM 收集每个调用字节码的类型配置文件。如果循环中有虚拟调用,则无论循环是否展开,调用都将只有一种类型的配置文件。

要从虚拟调用优化中获得最佳效果,您需要手动拆分循环,主要是为了拆分类型配置文件。到目前为止,HotSpot 无法自动执行此操作。

【讨论】:

  • 感谢您的出色回答。仅出于完整性考虑:您是否知道任何可能为特定实例生成代码的 JITC 技术?
  • @Alexander HotSpot 不会针对特定实例优化代码。它使用包括每个字节码计数器、类型配置文件、分支目标概率等在内的运行时统计信息。如果您想针对特定情况优化代码,请手动或使用动态字节码生成为其创建一个单独的类。
猜你喜欢
  • 1970-01-01
  • 2021-10-01
  • 2020-05-30
  • 2011-05-16
  • 2011-01-21
  • 1970-01-01
  • 2013-09-21
  • 2010-09-29
相关资源
最近更新 更多