it-swarm.com.ru

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

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

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

Я уже пытался использовать 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<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 выполнялся вместе с несколькими засыпаниями:

  • Несколько циклов: 4,9661 секунды
  • SingleLoop: 7,2725 секунд

OpenJDK 1.8.0_144, работающий на Intel i7-7500U (Kaby Lake).

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

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

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

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

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

Результаты:

  • Несколько циклов: 0,9339 секунд
  • SingleLoop: 0,66590005 секунд

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

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

Результаты:

  • Несколько циклов: 7,397 секунды
  • SingleLoop: 8,092 секунд
23
Francisco Ribeiro

Я исследовал этот «феномен» и, похоже, получил что-то вроде ответа.
Давайте добавим .jvmArgs("-verbose:gc") к JMHs OptionsBuilder. Результаты за 1 итерацию:

Одиночный цикл: [Полная ГХ (эргономика) [PSYoungGen: 2097664K-> 0K (2446848K)] [ParOldGen: 3899819K-> 4574771K (5592576K)] 5997483K-> 4574771K (8039424K), [метаспекта: 6208] 6208) , 5,0438301 с] [Время: пользователь = 37,92 сис = 0,10, реальное = 5,05 с] 4,954 с/оп

Несколько циклов: [Полный GC (эргономика) [PSYoungGen: 2097664K-> 0K (2446848K)] [ParOldGen: 3899819K-> 4490913K (5592576K)] 5997483K-> 4490913K (8039424K) (8039424K), [Metaspace] 620: 620: 620: 620: 620: 620: 620: 620: 620: 620: 620: 620: 620: 620: 620: 620: 620: 620) 620: 620: 620: 620): 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 8 6 6 6 8 6 6 6 8 6 6 6 8 6 6 6 8 6 6 6 8 6 6 6 6 6 8 6 6 6 6 6 8 6 6 8 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 8 6 (((6) = 8 , 3.7991573 с] [Время: пользователь = 26,84 сс = 0,08, реал = 3,80 с] 4,187 с/оп

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

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

Один цикл: gc.churn.PS_Eden_Space 336.037 МБ/с ± 19 МБ/с

Я думаю, мы видим ускорение в таких специфических обстоятельствах, потому что старый добрый алгоритм Compare и Swap GC имеет узкое место в ЦП для множественных тестовых запусков и использует дополнительный «бессмысленный» цикл для сбора мусора из предыдущих запусков. Воспроизвести с помощью @Threads(2) еще проще, если у вас достаточно оперативной памяти. Это выглядит так, если вы попытаетесь профилировать тест Single_Loop:

profiling

2
Anton Kot

Чтобы понять, что происходит внутри, вы можете добавить поведение JMX для анализа работающего приложения в jvisualvm, расположенном в Java_HOME\bin При размере списка капсул в памяти 20 МБ он запустился из памяти и visualvm перешел в неотвечающее состояние. Я уменьшил размер списка капсул до 200К и от 100М до 1М в случае необходимости тестирования. После наблюдения поведения на 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

1
Aditya