Почему две отдельные циклы быстрее, чем одна?

Я хочу понять, какие оптимизации Java делает для последовательных циклов. Точнее, я пытаюсь проверить, выполняется ли слияние фьюжн. Теоретически я ожидал, что эта оптимизация не была выполнена автоматически и ожидала подтверждения того, что плавная версия была быстрее, чем версия с двумя циклами.

Однако после запуска тестов результаты показывают, что две отдельные (и последовательные) контуры быстрее, чем один цикл, выполняющий всю работу.

Я уже пробовал использовать JMH для создания тестов и получения тех же результатов.

Я использовал команду javap и это показывает, что сгенерированный байт-код для исходного файла с двумя циклами фактически соответствует двум выполняемым 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<Capsule> с 20 миллионами элементов, заполненных таким образом в начале:

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 исполнялся вместе с несколькими спит:

  • MultipleLoops: 4.9661 секунд
  • SingleLoop: 7.2725 секунд

OpenJDK 1.8.0_144 работает на Intel i7-7500U (озеро Каби).

Почему версия MultipleLoops быстрее, чем версия SingleLoop, хотя она должна пересекать две разные структуры данных?

ОБНОВЛЕНИЕ 1:

Как было предложено в комментариях, если я изменяю реализацию для вычисления totalLength при totalLength строк, избегая создания списка res, версия с одним циклом становится быстрее.

Однако эта переменная была введена только для того, чтобы некоторая работа была выполнена после создания списка результатов, чтобы избежать отбрасывания элементов, если с ними ничего не было сделано.

Другими словами, предполагаемый результат - составить окончательный список. Но это предложение помогает лучше понять, что происходит.

Результаты:

  • MultipleLoops: 0,9339 секунд
  • SingleLoop: 0.66590005 секунд

ОБНОВЛЕНИЕ 2:

Вот ссылка на код, который я использовал для теста JMH: https://gist.github.com/FranciscoRibeiro/2d3928761f76e4f7cecfcfcdf7fc96d5

Результаты:

  • MultipleLoops: 7.397 секунд
  • SingleLoop: 8.092 секунд

Ответ 1

Я исследовал эти "явления" и выглядел как что-то вроде ответа.
Пусть добавляет .jvmArgs("-verbose:gc") в JMHs OptionsBuilder. Результаты для 1 Итерация:

Single Loop: [Полный GC (Эргономика) [PSYoungGen: 2097664K-> 0K (2446848K)] [ParOldGen: 3899819K-> 4574771K (5592576K)] 5997483K-> 4574771K (8039424K), [Metaspace: 6208K-> 6208K (1056768K)], 5.0438301 secs] [Times: user = 37.92 sys = 0.10, real = 5.05 secs] 4.954 s/op

Несколько циклов: [Полный GC (Эргономика) [PSYoungGen: 2097664K-> 0K (2446848K)] [ParOldGen: 3899819K-> 4490913K (5592576K)] 5997483K-> 4490913K (8039424K), [Metaspace: 6208K-> 6208K (1056768K)], 3.7991573 secs] [Times: user = 26.84 sys = 0.08, real = 3,80 с] 4.187 с /op

JVM потратила огромное количество процессорного времени на GC. Один раз за 2 тестовых пробега JVM должен сделать Full GC (переместить 600Mb в OldGen и собрать 1,5Gb мусора из предыдущих циклов). Оба сборщика мусора выполнили ту же работу, но потратили ~ 25% меньше времени приложения для теста с несколькими циклами. Если мы POPSIZE до 10_000_000 или добавим до bh.consume() Thread.sleep(3000) или добавим -XX:+UseG1GC к аргументам JVM, то эффект усиления нескольких циклов исчезнет. Я снова запускаю его с помощью .addProfiler(GCProfiler.class). Основное различие:

Несколько циклов: gc.churn.PS_Eden_Space 374.417 ± 23 МБ/с

Single Loop: gc.churn.PS_Eden_Space 336.037 МБ/с ± 19 МБ/с

Я думаю, что мы наблюдаем ускорение в таких конкретных обстоятельствах, потому что алгоритм сравнения с хорошим и быстрым обменом GC имеет узкое место процессора для многопроцессорных тестовых прогонов и использует дополнительный "бессмысленный" цикл для сбора мусора с более ранних запусков. Это еще проще воспроизвести с помощью @Threads(2), если у вас достаточно ОЗУ. Похоже, что если вы попытаетесь профилировать тест Single_Loop:

profiling

Ответ 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