【问题标题】:Why are two separate loops faster than one?为什么两个独立的循环比一个快?
【发布时间】:2018-08-03 18:48:47
【问题描述】:

我想了解 Java 对连续 for 循环做了哪些优化。更准确地说,我正在尝试检查是否执行了循环融合。 从理论上讲,我期望这种优化不是自动完成的,并且期望确认融合版本比具有两个循环的版本更快。

但是,在运行基准测试后,结果表明两个独立(连续)循环比一个循环完成所有工作要快。

我已经尝试使用 JMH 创建基准测试并获得了相同的结果。

我使用了javap 命令,它显示为具有两个循环的源文件生成的字节码实际上对应于正在执行的两个循环(没有执行循环展开或其他优化)。

正在为BenchmarkMultipleLoops.java 测量代码:

private void work() {
        List<Capsule> intermediate = new ArrayList<>();
        List<String> res = new ArrayList<>();
        int totalLength = 0;

        for (Capsule c : caps) {
            if(c.getNumber() > 100000000){
                intermediate.add(c);
            }
        }

        for (Capsule c : intermediate) {
            String s = "new_word" + c.getNumber();
            res.add(s);
        }

        //Loop to assure the end result (res) is used for something
        for(String s : res){
            totalLength += s.length();
        }

        System.out.println(totalLength);
    }

正在为BenchmarkSingleLoop.java 测量代码:

private void work(){
        List<String> res = new ArrayList<>();
        int totalLength = 0;

        for (Capsule c : caps) {
            if(c.getNumber() > 100000000){
                String s = "new_word" + c.getNumber();
                res.add(s);
            }
        }

        //Loop to assure the end result (res) is used for something
        for(String s : res){
            totalLength += s.length();
        }

        System.out.println(totalLength);
    }

这是Capsule.java的代码:

public class Capsule {
    private int number;
    private String word;

    public Capsule(int number, String word) {
        this.number = number;
        this.word = word;
    }

    public int getNumber() {
        return number;
    }

    @Override
    public String toString() {
        return "{" + number +
                ", " + word + '}';
    }
}

caps 是一个 ArrayList&lt;Capsule&gt;,一开始就填充了 2000 万个元素:

private void populate() {
        Random r = new Random(3);

        for(int n = 0; n < POPSIZE; n++){
            int randomN = r.nextInt();
            Capsule c = new Capsule(randomN, "word" + randomN);
            caps.add(c);
        }
    }

在测量之前,执行预热阶段。

我对每个基准测试运行了 10 次,换句话说,work() 方法对每个基准测试执行了 10 次,完成的平均时间如下所示(以秒为单位)。每次迭代后,GC 都会执行一些休眠:

  • 多个循环:4.9661 秒
  • 单循环:7.2725 秒

在 Intel i7-7500U (Kaby Lake) 上运行的 OpenJDK 1.8.0_144。

为什么 MultipleLoops 版本比 SingleLoop 版本更快,即使它必须遍历两种不同的数据结构?

更新 1:

按照 cmets 中的建议,如果我在生成字符串时更改实现以计算 totalLength,避免创建 res 列表,单循环版本会变得更快。

但是,该变量仅被引入,以便在创建结果列表后完成一些工作,以避免在未对它们进行任何操作时丢弃元素。

换句话说,预期的结果是生成最终列表。但这个建议有助于更好地理解正在发生的事情。

结果:

  • 多个循环:0.9339 秒
  • 单循环:0.66590005 秒

更新 2:

这是我用于 JMH 基准测试的代码的链接: https://gist.github.com/FranciscoRibeiro/2d3928761f76e4f7cecfcfcdf7fc96d5

结果:

  • 多个循环:7.397 秒
  • 单循环:8.092 秒

【问题讨论】:

  • 如果你直接增加totalSize,跳过创建result列表会发生什么?
  • 我猜是因为分支预测。 stackoverflow.com/questions/11227809/…
  • 刚刚尝试重现您的结果但没有成功。对我来说,两个循环都有大约。同样的表现,见gist.github.com/l-wi/20a923af1ee707e885e087e136af0bfe。您(或我的)基准代码是否存在缺陷?
  • 请提供 MCVE。强调 C ... 完成。您说:“我已经尝试使用 JMH 创建基准测试并得到相同的结果。” 那么您应该将问题中的代码替换为基于 JMH 的版本和相应的结果。现在,有人说他们无法重现您的结果,但我们/他们不知道他们是否运行与您相同的基准测试。
  • 先尝试进行 SingleLoops 测试。可能是您首先运行的测试在您第二次运行的测试期间创建了 GC 工作。

标签: java performance optimization benchmarking microbenchmark


【解决方案1】:

我调查了这种“现象”,看起来像是得到了答案。
让我们将 .jvmArgs("-verbose:gc") 添加到 JMHs OptionsBuilder。 1 次迭代的结果:

单循环:[Full GC (Ergonomics) [PSYoungGen: 2097664K->0K(2446848K)] [ParOldGen: 3899819K->4574771K(5592576K)] 5997483K->4574771K(8039424K), [2088K-> 1056768K)​​],5.0438301 秒] [时间:用户=37.92 系统=0.10,实际=5.05 秒] 4.954 秒/运算

多个循环:[Full GC (Ergonomics) [PSYoungGen: 2097664K->0K(2446848K)] [ParOldGen: 3899819K->4490913K(5592576K)] 5997483K->4490913K(8039424K), [208Metaspace: 6208K->( 1056768K)​​], 3.7991573 secs] [Times: user=26.84 sys=0.08, real=3.80 secs] 4.187 秒/运算

JVM 为 GC 花费了大量的 CPU 时间。每 2 次测试运行一次,JVM 必须进行 Full GC(将 600Mb 移动到 OldGen 并从之前的周期中收集 1.5Gb 的垃圾)。两个垃圾收集器都完成了相同的工作,但多循环测试用例的应用时间减少了约 25%。如果我们将 POPSIZE 减少到 10_000_000 或在 bh.consume() 之前添加 Thread.sleep(3000),或者在 JVM args 中添加 -XX:+UseG1GC,那么多循环提升效果就消失了。我用.addProfiler(GCProfiler.class) 再次运行它。主要区别:

多个循环:gc.churn.PS_Eden_Space 374.417 ± 23 MB/秒

单循环:gc.churn.PS_Eden_Space 336.037 MB/秒 ± 19 MB/秒

我认为,我们在这种特定情况下看到了加速,因为旧的比较和交换 GC 算法在多次测试运行时存在 CPU 瓶颈,并且从早期运行中收集垃圾使用了额外的“无意义”循环。如果您有足够的 RAM,使用 @Threads(2) 重现会更容易。如果您尝试分析 Single_Loop 测试,它看起来像这样:

【讨论】:

  • 发布问题后的一段时间,我用不同的 GC 算法做了一些实验:Serial GC、Parallel GC 和 G1GC。 Parallel GC 似乎对 SingleLoop 的性能影响最大。使用 G1GC 或 Serial GC 缩短了 Multiple 和 Single 之间的差异,但我能做到的最好的结果是使 SingleLoop 的性能几乎与 MultipleLoops 相同。您的实验似乎指向同一个方向,即 GC 是“罪魁祸首”。谢谢!
  • 可能是,我不是很清楚。我们的性能提升主要是因为比较和交换 GC(也是并行的)使用多个 CPU 内核欺骗 JMH,即使您明确设置 @Thread=1 也是如此。谢谢你有趣的问题,祝你好运;)
【解决方案2】:

要了解幕后发生的事情,您可以添加 JMX 行为以分析 jvisualvm 中正在运行的应用程序,该应用程序位于 JAVA_HOME\bin 内存中有 20M 大小的胶囊列表,内存不足,visualvm 进入无响应状态。在测试条件下,我已将胶囊列表大小减少到 200k,将 100M 减少到 1M。观察 visualvm 上的行为后,单循环执行在多个循环之前完成。也许这不是正确的方法,但您可以尝试一下。

LoopBean.java

import java.util.List;
public interface LoopMBean {
    void multipleLoops();
    void singleLoop();
    void printResourcesStats();
}

Loop.java

import java.util.ArrayList;
import java.util.List;
import java.util.Random;

public class Loop implements LoopMBean {

    private final List<Capsule> capsules = new ArrayList<>();

    {
        Random r = new Random(3);
        for (int n = 0; n < 20000000; n++) {
            int randomN = r.nextInt();
            capsules.add(new Capsule(randomN, "word" + randomN));
        }
    }

    @Override
    public void multipleLoops() {

        System.out.println("----------------------Before multiple loops execution---------------------------");
        printResourcesStats();

        final List<Capsule> intermediate = new ArrayList<>();
        final List<String> res = new ArrayList<>();
        int totalLength = 0;

        final long start = System.currentTimeMillis();

        for (Capsule c : capsules)
            if (c.getNumber() > 100000000) {
                intermediate.add(c);
            }

        for (Capsule c : intermediate) {
            String s = "new_word" + c.getNumber();
            res.add(s);
        }

        for (String s : res)
            totalLength += s.length();

        System.out.println("multiple loops=" + totalLength + " | time taken=" + (System.currentTimeMillis() - start) + " milliseconds");

        System.out.println("----------------------After multiple loops execution---------------------------");
        printResourcesStats();

        res.clear();
    }

    @Override
    public void singleLoop() {

        System.out.println("----------------------Before single loop execution---------------------------");
        printResourcesStats();

        final List<String> res = new ArrayList<>();
        int totalLength = 0;

        final long start = System.currentTimeMillis();

        for (Capsule c : capsules)
            if (c.getNumber() > 100000000) {
                String s = "new_word" + c.getNumber();
                res.add(s);
            }

        for (String s : res)
            totalLength += s.length();

        System.out.println("Single loop=" + totalLength + " | time taken=" + (System.currentTimeMillis() - start) + " milliseconds");
        System.out.println("----------------------After single loop execution---------------------------");
        printResourcesStats();

        res.clear();
    }

    @Override
    public void printResourcesStats() {
        System.out.println("Max Memory= " + Runtime.getRuntime().maxMemory());
        System.out.println("Available Processors= " + Runtime.getRuntime().availableProcessors());
        System.out.println("Total Memory= " + Runtime.getRuntime().totalMemory());
        System.out.println("Free Memory= " + Runtime.getRuntime().freeMemory());
    }
}

LoopClient.java

import javax.management.MBeanServer;
import javax.management.ObjectName;
import java.lang.management.ManagementFactory;

public class LoopClient {

    void init() {

        final MBeanServer mBeanServer = ManagementFactory.getPlatformMBeanServer();
        try {
            mBeanServer.registerMBean(new Loop(), new ObjectName("LOOP:name=LoopBean"));
        } catch (Exception e) {
            e.printStackTrace();
        }

    }

    public static void main(String[] args) {

        final LoopClient client = new LoopClient();
        client.init();
        System.out.println("Loop client is running...");
        waitForEnterPressed();
    }

    private static void waitForEnterPressed() {
        try {
            System.out.println("Press  to continue...");
            System.in.read();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

使用以下命令执行:

java -Dcom.sun.management.jmxremote -Dcom.sun.management.jmxremote.port=9999 -Dcom.sun.management.jmxremote.authenticate=false -Dcom.sun.management.jmxremote.ssl=false LoopClient

您可以添加 -Xmx3072M 额外选项以快速增加内存以避免 OutOfMemoryError

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 2022-01-12
    • 2019-06-21
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2015-02-05
    • 1970-01-01
    • 2020-09-09
    相关资源
    最近更新 更多