Почему две отдельные циклы быстрее, чем одна?
Я хочу понять, какие оптимизации 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:
Ответ 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