4. Насколько Stream API медленнее?
static long sumTwice(int max) {
long sum = 0;
for(int i=1; i<=max; i++) sum+=i*2;
return sum;
}
4
static long sumTwiceStream(int max) {
return IntStream.rangeClosed(1, max)
.mapToLong(x -> x*2).sum();
}
5. Спрашивает StackOverflow
5
http://stackoverflow.com/q/31761271/4856258 (удалено автором)
Predicate<Integer> в Java 8 быстрее, чем IntPredicate
В Java 8 предлагается использовать IntPredicate вместо Predicate<Integer>и
аналогично для других примитивных типов, так как таким образом можно
избавиться от накладных расходов на автобоксинг, но когда я запускаю
нижеследующий код, я получаю совершенно противоположный результат:
на моей системе IntPredicate в 30-50 раз медленее, чем Predicate.
In Java 8 Predicate<Integer> is faster than IntPredicate
In Java 8 it is suggested that we should use IntPredicate rather than Predicate<Integer> and same for
other premitive types as former one reduces the overhead related to autoboxing but when i run the
following code. I get results shockingly opposite as IntPredicate is 30-50 times slower than Predicate
on my system.
7. Реакция сообщества
Это потрясающе плохой бенчмарк.
В него можно внести несколько
кардинальных улучшений, и это
всё равно будет плохой бенчмарк.
– Marko Topolnik
This benchmark is shockingly bad. It could be
improved in several significant ways and still be a
bad benchmark.
7
8. Наивняк
public static void main(String[] args) {
long startSimple = System.nanoTime();
long resultSimple = sumTwice(10_000_000);
long endSimple = System.nanoTime();
System.out.printf("Simple: %d; time=%8.3fms%n",
resultSimple, (endSimple-startSimple)/1_000_000.0);
long startStream = System.nanoTime();
long resultStream = sumTwiceStream(10_000_000);
long endStream = System.nanoTime();
System.out.printf("Stream: %d; time=%8.3fms%n",
resultStream, (endStream-startStream)/1_000_000.0);
}
8
22. JMH benchmark
public class MyBenchmark {
@Benchmark
public long stream() {
return sumTwiceStream(10_000_000);
}
@Benchmark
public long simple() {
return sumTwice(10_000_000);
}
...
}
22
23. JMH benchmark
@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 10, time = 1, timeUnit = TimeUnit.SECONDS)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@Fork(5)
@State(Scope.Benchmark)
public class MyBenchmark {
...
}
[jmhtest/target]$ java –jar benchmark.jar >out.txt
23
24. JMH benchmark – результаты
# JMH 1.11.1 (released 7 days ago)
# VM version: JDK 1.8.0_60, VM 25.60-b23
...
Benchmark Mode Cnt Score Error Units
MyBenchmark.simple avgt 50 4.535 ± 0.009 ms/op
MyBenchmark.stream avgt 50 4.123 ± 0.009 ms/op
____________________________________________________________
Simple: 100000010000000; time= 8.286ms
Stream: 100000010000000; time= 57.774ms
24
25. JMH benchmark – результаты
# JMH 1.11.1 (released 7 days ago)
# VM version: JDK 1.8.0_60, VM 25.60-b23
...
Benchmark Mode Cnt Score Error Units
MyBenchmark.simple avgt 50 4.535 ± 0.009 ms/op
MyBenchmark.stream avgt 50 4.123 ± 0.009 ms/op
____________________________________________________________
Simple: 100000010000000; time= 8.286ms
Stream: 100000010000000; time= 57.774ms
25
26. JMH – параметризуем
@Param({"100000", "1000000", "10000000"})
private int n;
@Benchmark
public long stream() {
return sumTwiceStream(n);
}
@Benchmark
public long simple() {
return sumTwice(n);
}
26
31. Наивняк с циклом
public static void main(String[] args) {
for(int i=0; i<6000; i++) {
long start = System.nanoTime();
long result = sumTwiceStream(10_000_000);
long end = System.nanoTime();
System.out.printf("#%d: %d; time=%8.3fms%n", i,
result, (end-start)/1_000_000.0);
}
}
31
34. -XX:+PrintCompilation: читаем лог
148 221 % 4 ...::forEachRemaining @ 34 (65 bytes)
tstamp compile_id attrs comp_level name [@ osr_pos] (size) [status]
• tstamp – время в миллисекундах с начала выполнения
• compile_id – номер задачи на компиляцию в очереди
• attrs – атрибуты
• comp_level – номер уровня tier-компиляции
• name – имя класса и метода
• osr_pos – позиция в байткоде, на которой выполняется OSR
• size – размер байткода метода в байтах (или “native”)
• status – дополнительная информация о событии
34
35. -XX:+PrintCompilation: атрибуты
148 221 % 4 ...::forEachRemaining @ 34 (65 bytes)
tstamp compile_id attrs comp_level name [@ osr_pos] (size) [status]
• n – обёртка для native-метода (по факту не компиляция)
• % – on-stack replacement
• s – метод объявлен synchronized
• ! – есть обработчик исключений
• b – компиляция блокирует выполнение
35
58. Баги в OpenJDK
• https://bugs.openjdk.java.net/browse/JDK-8015416
– tier one should collect context-dependent split profiles
• https://bugs.openjdk.java.net/browse/JDK-8015417
– profile pollution after call through invokestatic to shared
code
58
59. На самом деле
public static long sumTwiceOpt(int max) {
return max*(max+1L);
}
Benchmark (n) Score Error Units
MyBenchmark.opt 100000 0.003 ± 0.001 us/op
MyBenchmark.opt 1000000 0.003 ± 0.001 us/op
MyBenchmark.opt 10000000 0.003 ± 0.001 us/op
59
60. Но всё не зря!
-Xint
-XX:UseOnStackReplacement
-XX:UseLoopCounter
-XX:MaxInlineLevel
-XX:Tier4InvocationThreshold
-XX:Tier4BackEdgeThreshold
-XX:UnlockDiagnosticVMOptions
-XX:PrintCompilation
-XX:TraceNMethodInstalls
-XX:PrintInlining
-XX:PrintFlagsFinal
60
Опции виртуальной машины
Инструменты
JMH JITWatch
61. Дополнительная информация
• Алексей Шипилёв «The Black Magic of (Java) Method Dispatch»
– http://shipilev.net/blog/2015/black-magic-method-dispatch/
• Владимир Иванов «Динамическая JIT-компиляция в JVM»
– http://www.youtube.com/watch?v=oYu3HuIYDhI
• Алексей Шипилёв «Java Benchmarking: как два таймстампа
прочитать!»
– http://shipilev.net/blog/2014/nanotrusting-nanotime/
– https://www.youtube.com/watch?v=Vb3jyHl3FNk
• Пол Сандоз отвечает на «Erratic performance of
Arrays.stream().map().sum()»
– http://stackoverflow.com/a/25851390/4856258
61
Здравствуйте! Меня зовут Валеев Тагир. Мой доклад – это своеобразная сказка, основанная на реальных событиях. Мы посмотрим, как бывает непросто разобраться в том, что работает быстрее или медленнее. Вас ждёт пара адских бенчмарков и немного всякой всячины про JIT-компиляцию в HotSpot (других JVM я не касаюсь). Все тесты будут проводиться на Oracle JDK 8u60.
Крутые докладчики обязательно показывают слайд-дисклеймер, я решил, что я не хуже, и тоже сделал такой. Вот только я не работаю в Оракле, так что дисклеймер писали не профессиональные юристы, а я сам, поэтому текста мало. Воспринимайте мои слова критически, не делайте далеко идущих выводов и проверяйте всё, что увидите, сами.
Последнее время я очень полюбил Java 8 Stream API. Настолько, что написал классную библиотеку, расширяющую возможности стримов. Взять можно на гитхабе или в мавене бесплатно, без регистрации и sms. Это была реклама. В общем, как я увидел стримы, я от них затащился и стал пихать куда ни попадя. И, конечно, захотелось разобраться с вопросом: а какова цена удобства? Насколько я проиграю по скорости по сравнению с традиционными решениями?
Ну как проверить? Надо придумать какую-нибудь задачу и замерить скорость её выполнения. Я решил, что надо взять какой-нибудь несложный тест, тогда будет проще разобраться в происходящем. Вот, например: берём целые числа от 1 до n, умножаем каждое на 2 и считаем сумму. Вот слева у нас старый добрый традиционный цикл. Всё простенько, без изысков. Результат запишем в long, чтоб не переполниться. Как видите, никаких выделений памяти и вообще обращений к куче, вычислений минимум, поэтому посторонних эффектов быть не должно. Справа у нас реализация на Stream API. Модно, функционально, современно, фичасто. Какая будет работать быстрее? Ну, надо думать, традиционная. Вопрос, насколько. Ну мы не хотим, чтобы тест занимал наносекунды, там могут быть погрешности, поэтому давайте возьмём max побольше, чтобы процедура заняла заметное время. Скажем, 10 миллионов, хорошее число. Устроим мортал комбат. Как будем измерять?
Я последнее время стал на StackOverflow частенько зависать. Вот там с завидной регулярностью вопросы появляются в духе приведённого на слайде. Можно не вчитываться, это всего лишь пример. Вопросы в духе: «а вот, я вот так измерил A, а оно оказалось быстрее Б, а мне говорили, должно быть наоборот, как же так?» Или, гораздо хуже, появляются подобные ответы с рекомендациями, используйте А, оно быстрее, я измерил!
При этом измерение выглядит примерно так. Ну а что такого? Измерил время в начале, измерил в конце, сравнил, вывел на экран.
Реакция на такие вопросы предсказуемо вот такая. Фейспалмы, минусики, голоса за закрытие, ехидные комментарии и так далее. Хотя справедливости ради замечу, иногда бывает, что потрясающе плохой бенчмарк всё равно обнаруживает какой-нибудь интересный эффект.
Давайте и мы начнём ламерским методом, чтобы просто понять, плох ли он и почему. Взяли, замерили время в начале, запустили функцию, замерили время в конце, вывели разность. Всё же до currentTimeMillis опускаться не будем, возьмём nanoTime. Нормально? Нормально. Запустим.
Вот типичный результат на типичной не сильно новой машине. Кстати, заметьте, что даже в этом наивняке мы сделали важную вещь: вывели не только время, но и результат. Во-первых, если результат не использовать, виртуальная машина в некоторых случаях может вообще обидеться и не вычислять его. А зачем? Всё равно никому не надо. Кто знает, как такая оптимизация называется? Правильно, dead code elimination. Во-вторых, если вы сравниваете скорость работы двух вариантов решения одной задачи, для начала стоит убедиться, что два варианта вообще выдают одинаковый результат. А то некоторые сравнивают правильный алгоритм с неправильным и потом хвастаются, что неправильный быстрее. Примерно как машинистка, которая печатает со скоростью 1000 знаков в минуту. Мы видим, что результат одинаковый, но время удручающее: стрим в семь раз медленнее, чем старый добрый цикл. Я на всякий случай запустил программу несколько раз, результаты более-менее стабильные. Семикратный провал в скорости – это всё, конец? Стримы безнадёжно медленные?
Ну да, мы знаем, что измерили неправильно. Но давайте попробуем понять, а что мы измерили? Давайте сперва с простым случаем разберёмся. 8 с лишним миллисекунд – это что за время?
Вы помните, в простом случае совершенно тривиальный метод, должно быть не сильно сложно. 8 миллисекунд – это что за время? И может ли оно стать быстрее при повторном выполнении кода?
Ну да, я что-то слышал, что сперва JVM интерпретирует код, а компилирует уже потом, при повторном выполнении метода. Примерно понятно, что интерпретатор едет медленно, как лошадь, зато сел и поскакал. А компилируемый код — это вроде паровоза, который едет быстрее, но его вначале надо раскочегарить, дров накидать. Если б все методы сразу JIT-компилировались, пришлось бы ждать, пока компиляция завершится, поэтому чтобы не терять времени, мы начинаем скакать на лошади, а в параллельном потоке раскочегаривается паровоз. Как потом пересесть с лошади на паровоз? Обычно это происходит на станции, то есть при повторном вызове метода. Но у нас только один метод sumTwice, он вызывается один раз и другие методы не вызывает. Выходит, шанса пересесть в паровоз на следующей станции у нас нету. Значит, 8 миллисекунд — это время выполнения кода интерпретатором? Или нет?
К чему гадать, если можно проверить? Запустим JVM в режиме интерпретатора с помощью опции –Xint. Вот здесь мы действительно измерили время выполнения интерпретатором. Ага, оказывается, что компилятор всё-таки был полезен в первом тесте: теперь оба теста стали гораздо медленнее, причём стрим проигрывает раз в 12. Тут мы, кстати, уже видим, что одна и та же Java-программа может выполняться существенно разное время в зависимости от опций JVM. Поэтому вопрос вроде «сколько времени будет выполняться эта программа» малоосмысленен без контекста. Как же компилятору удаётся помочь в первом тесте?
Не все знают, но у виртуальной машины HotSpot есть интересная фича OnStackReplacement (или OSR) – замена на стеке. Когда интерпретируемый метод содержит много обратных переходов (back edges), то есть в нём есть длинный цикл, этот метод компилируется JIT-компилятором в особый вид, который позволяет перепрыгнуть из медленного интерпретатора в быстрый скомпилированный код на полном скаку. Прыжок обычно производится в начале очередной итерации цикла. Разумеется, при этом аккуратно переносятся все переменные и вообще контекст выполнения. Как проверить, действительно ли OSR помогает в нашем случае? Очень просто – попробуем его отключить.
Для этого, разумеется, тоже есть опция JVM – -UseOnStackReplacement. И тут сразу неожиданный результат: с этим параметром простая версия работает существенно медленнее, чем вообще без компилятора: 150 миллисекунд против 136. На самом деле виртуальная машина HotSpot — чрезвычайно настраиваемая штука. OnStackReplacement срабатывает, когда достигается сколько-то обратных переходов, но их ещё же надо сосчитать. Это делает отдельная подсистема — loop counter. В режиме интерпретатора она выключена, потому что не нужна. А вот выключив только OnStackReplacement, мы loop counter не выключили, и он добавляет работы интерпретатору, собирая статистику, сколько было обратных переходов.
Отдельной опцией –UseLoopCounter можно выключить и его. Теперь время выполнения простого теста сравнялось с интерпретатором, так что похоже действительно OSR сильно помогал. Зато заметьте, что стрим версия хотя без OSR работает медленнее, но всё же гораздо быстрее, чем без интерпретатора вообще. Теперь она всего в 2.5 раза проигрывает. Как вы думаете, почему? Как компилятор помогает стрим-версии при отсутствии OSR?
Чтобы понять, почему так происходит, надо взглянуть на устройство Stream API. Если кто-то ещё не в курсе, стримы по своей природе ленивы. Вы сперва создаёте стрим, потом можете использовать промежуточные (или intermediate) операции, при этом вычислений не происходит, просто операции добавляются в конвейер. А в конце вы запускаете ровно одну конечную или терминальную операцию (в данном случае sum), которая и делает всю работу. Это как в армии: «взвод, в направлении отдельно стоящего дерева, направляющее первое отделение, бегом – марш!» Пока «марш» не сказали, все стоят и слушают, что делать, а потом бегут. «Стрим, целые числа от одного до макс, умножив каждое на два, сложить!»
Посмотрим, что происходит внутри этой конечной операции sum. Кто ловил исключения в стримах, знает, какие там суровые стектрейсы. Тут вот нарисована основная последовательность вызовов, в которой производятся собственно вычисления. Там ещё немало побочных методов вызывается, я их не стал приводить, иначе слайд бы совсем растаращило. Здесь только то, что ведёт непосредственно к нашим вычислениям. Sum на самом деле вызывает reduce с функцией Long::sum. Reduce вызывает… на самом деле нам не очень важно, что она вызывает.
Чтобы не углубляться, будем считать, что это работает так и в какой-то момент мы сваливаемся в RangeIntSpliterator, где и выполняется цикл на 10 миллионов элементов (цикл while, если быть точным). То есть всё вышестоящее пролетает довольно быстро, а вот тут начинается реальное вычисление. Далее вызывается реализация тела mapToLong, которая в свою очередь вызывает нашу лямбду для умножения и передаёт управление реализации reduce-операции, а она суммирует.
То есть даже без OSR-механизма но со включенной компиляцией можно скомпилировать вот это всё, и с какой-то итерации цикла оно начинает работать быстрее.
Таким образом, про самый первый результат вообще сложно сказать, что же мы измерили. Это частично интерпретированный, частично скомпилированный код в какой-то сложной пропорции. Во втором случае ещё более-менее ясно, что мы измеряли скорость работы интерпретатора. Цифры практически бесполезные, но по крайней мере понятно. И то здесь ещё надо учесть задержки на загрузку тонны вспомогательных классов из Stream API, генерации рантайм-представления из лямбда-выражений и так далее. Поэтому если повторить тест, то даже в режиме интерпретации второй и последующие результаты будут процентов на 10 быстрее. А в первом случае полное мясо, цифры, которые ничего не значат. Если вам надо один раз решить задачу, вас же не волнует, завершится она за 8 миллисекунд или за 60. Вы в любом случае моргнуть не успеете. А если задачу выполнять много раз, то цифры будут совсем другие.
Так что прекращаем измерять напряжение языком и возьмём, наконец, вольтметр. Есть несколько вариантов, но мы не будем выделяться из толпы и остановимся на JMH. Про JMH рассказывала куча докладчиков, но по моему впечатлению, многие до сих пор боятся, что поставить и настроить его сложно. Спешу заверить, это как раз несложно. В инструкции на сайте сказано, что нужен мавен, но если так случилось, что вы им никогда не пользовались, это не страшно. По факту нужны две команды, остальное всё заработает волшебным образом само. Одна длинная, вот она. Запоминать не надо, можно скопипастить. Её набираете один раз, мавен пошуршит, и у вас будет готовая болванка проекта для бенчмаркинга с тестовым классом. И вторая короткая – для сборки. Ещё загляните в pom.xml, там есть строчка javac.target, для наших тестов надо поставить 1.8. Всё, остальное можно не трогать и не вникать. Конечно, любой Java-программист просто обязан изучить мавен, но конкретно для JMH это необязательно.
Вот давайте возьмём этот автогенерированный MyBenchmark.java и напишем там такой тест. Внизу опущены реализации sumTwice и sumTwiceStream, точно такие же, как в наивной версии. Результат вычислений можно просто вернуть из метода и не беспокоиться, что JIT-компилятор удалит вычисления. JMH любой возвращённый результат поглощает в специальную чёрную дыру, чтобы сделать вид, что он нам на самом деле нужен.
Сам класс надо аннотировать для настройки. По умолчанию JMH измеряет не среднее время, а обратную величину – throughput. Чтобы результаты были похожи на наивный замер, установим режим среднего времени и единицу измерения – миллисекунды. Будем 5 секунд гонять вычисления без замера для разогрева, чтобы всё уж точно скомпилировалось, загрузилось и т. д. Потом 10 раз по секунде будем делать замеры. JMH не один раз запустит наш метод, а столько раз, сколько влезет в секунду. Чтобы сделать замер ещё надёжнее, укажем Fork пять. Это означает, что весь тест мы повторим пять раз честно с перезапуском виртуальной машины и повторным разогревом. Таким образом у нас будет 50 измеряемых итераций.
После сборки в каталоге target появится файл benchmark.jar. Надо его просто запустить, перенаправив вывод в файл, а потом изучать содержимое файла.
Сперва будет детальный вывод по каждому из тестов, а потом общий результат. Время, которое нас интересует, в колонке Score. Внизу я скопировал для сравнения результаты наивной версии. Видно, что после разогрева традиционная реализация ускорилась почти вдвое. Но в стрим, похоже, воткнули реактивный двигатель. Он отработал в 14 раз быстрее. Любопытно, что стрим отработал даже быстрее простой реализации. Несильно быстрее, процентов на 10, но этот результат статистически значим: ошибка-то весьма маленькая.
То есть вы понимаете? Стримы-то мало того что удобнее и прикольнее, они ещё и быстрее! Мы ж вольтметром измеряли, всё правильно сделано. Я даже на разных машинах проверял, по крайней мере на интел 64 бит архитектуре везде результат близкий.
То есть всё здорово! Пони прыгают и радуются! Будем теперь стримы везде пихать? Программы только быстрее от этого станут.
Впрочем ни один учёный не будет сообщать об открытии, сделав измерение только в одних условиях. Поэтому и мы не будем спешить. Замер был сделан только для 10 миллионов элементов. Давайте для разнообразия повторим его для 100 тысяч и одного миллиона. С помощью JMH это легко сделать. Заводим какое-нибудь поле в классе. Аннотируем его @Param, в скобках перечисляем возможные значения, только обязательно в строковом виде, чтобы эта аннотация работала для любых типов. И потом свободно используем этот параметр в тестах. JMH сам переберёт перечисленные значения для каждого бенчмарка и соберёт результат в красивую табличку.
И мы имеем вот такие результаты. Традиционная реализация ведёт себя совершенно логично: в 10 раз больше параметр – в 10 раз больше время. А вот стрим над нами поиздевался. Смотрите: 100 тысяч элементов обрабатывается 560 микросекунд, миллион элементов – 5.7 миллисекунд, а 10 миллионов элементов – 4 миллисекунды, как и прежде! Это как вообще так? Миллион перебрать медленнее, чем 10 миллионов? На 10 миллионах стримы на коне, а если меньше, врубается черепаший режим, причём проигрыш по скорости уже даже не в 7 раз, а в 13! Погрешности небольшие, результат воспроизводится на разных машинах. Что происходит, есть идеи, куда копать? И что теперь, использовать стримы или не использовать? Использовать, только если число элементов больше 10 миллионов?
Первым делом, конечно, стоит глянуть в лог замеров. Он не зря выводится. Вот как выглядит первый форк из пяти для стрим-реализации при n равном миллион. В остальных очень похожие цифры. Не замечаете ничего странного?
Ага, первые две разогревочные итерации всё работало быстро: 440 микросекунд, 410 микросекунд – такой результат нас бы устроил. Потом на третьей что-то случилось, и всё, пошло-поехало.
А вот для сравнения лог для 10 миллионов замеров. Тут как и ожидается, начинаем в 10 раз медленнее – 4.3 миллисекунды, 4.1 миллисекунда, но дальше почему-то не пошло и не поехало. Как думаете, почему? На самом деле просто не успело. Дело в том, что скорость падает после определённого количества вызовов метода sumTwiceStream. При n = миллион это количество достигается уже на третьей разогревочной итерации, а для 10 миллионов в итерацию влезает меньше вызовов.
Как это проверить? Давайте добавим ещё 10 итераций. Для этого, кстати, необязательно исправлять аннотацию и перекомпилировать исходник: собранный benchmark.jar поддерживает кучу параметров командной строки, в том числе чтобы переопределить аннотации. Запустите с параметром –help, чтобы увидеть полный список. Нам нужен параметр –i 20. Запустим и увидим, что теперь и 10 миллионов сломали, на 18-й итерации скорость начала падать и дальше упала настолько же. Так что даже для 10 миллионов операций стрим-версия рано или поздно сдуется. Пони грустят. Всё, стримы не используем?
Давайте прикинем, сколько вызовов sumTwiceStream проходит перед поломкой. У нас успешно отработало 5 разогревочных итераций и 17 основных – всего примерно 22 секунды, а потом произошла поломка. По результатам выше один тест в среднем занимает около 4 миллисекунд, делим 22 секунды на 4 миллисекунды, получим оценку, что что-то ломается где-то в районе итерации 5500.
Чтобы посмотреть на этот эффект точнее, вспомним про старую добрую наивную версию. Простой вариант выкинем, он нам не нужен уже. Вместо этого добавим цикл и будем выводить номер итерации. Возможно, замер времени будет не такой точный, как с помощью JMH, но нам сейчас точные времена не нужны. Нам важно поймать момент, когда всё станет работать в 15 раз медленнее.
Видно, что уже к третьей итерации достигается максимальная производительность около 4 миллисекунд и она поддерживается до 5634-й итерации, после чего скорость резко падает, прямо в один момент, без какого-то переходного периода. При уменьшении параметра max этот неприятный момент наступает на несколько итераций позже, но похоже, что есть некоторый предел в 5000 с лишним итераций. Что может произойти такого с нашей программой после фиксированного числа итераций, не зависящего от размера задачи?
Есть подозрение, JIT-компилятор что-то неудачно перекомпилировал. Давайте посмотрим, чем он вообще занят. Для этого есть опция –XX:+PrintCompilation. С ней в стандартный вывод будет валиться много сообщений вроде приведённых на этом слайде. Если вы никогда эту опцию не использовали, вы можете удивиться, узнав, как много методов компилируется даже для простой программы. Видно причём, что в начале работы компилятор очень напряжённо работает, а потом компиляция почти прекращается. В этом логе легко увидеть имена компилируемых методов, а кроме того есть много дополнительных непонятных цифр и значков. Вообще вывод жёстко не специфицирован и может меняться в разных версиях JVM. В восьмёрке выводится вот так.
Давайте присмотримся к этим буквам и цифрам. Самое левое число – это всего лишь таймстамп, время в миллисекундах с запуска виртуальной машины. Второе число — это номер задачи компиляции в очереди. Иногда они идут не по порядку, более горячий метод может пролезть вперёд других. Потом несколько атрибутов, далее номер уровня tier-компиляции. Потом имя класса и имя метода. Собачка с числом выводится только для on-stack-replacement компиляции и означает позицию в байткоде метода, на которой происходит передача управления. Далее в скобках размер байткода метода в байтах или слово native, если это нативный метод. И в конце может быть ещё какой-нибудь дополнительный статус.
Атрибутов разных всего пять. Нас в данном случае интересует разве что процентик — это как раз признак OSR-компиляции. Остальные — это генерация native-обёртки, признак синхронизации или наличия обработчиков исключений. Флаг b означает, что компиляция выполняется не в фоновом потоке, а блокирует выполнение, в восьмой версии HotSpot с настройками по умолчанию вы его не увидите.
Стоит также упомянуть о tiered-компиляции, или многоуровневой компиляции. Вообще в HotSpot встроено два компилятора: С1 и C2. Исторически Sun, а затем Oracle поставлял две разные виртуальных машины - Client JVM и Server JVM, и вы могли выбирать, какую использовать, с помощью опций -client и -server. Компилятор C2 изначально был в серверной версии, а C1 - в клиентской. Затем оба компилятора стали использоваться вместе в рамках серверной JVM для tiered-компиляции. C1 значительно проще и компилирует быстрее (разница в скорости компиляции может быть на порядок и даже больше), но при этом используется гораздо меньше всяких оптимизаций, и код может работать медленнее. Сейчас в серверной JVM по умолчанию используются оба компилятора, и это контролируется указано в поле comp_level. 0 обычно относится к интерпретируемому коду. В логе компиляции этот уровень встречается для native-методов. Далее идёт C1 с частичным и полным сбором статистики, а уровень 4 соответствует компилятору C2. Обычно из всех уровней от 1 до 3 используется только один на основании разных настроек, размера метода, загруженности очереди компиляции и прочего. Чаще всего метод сразу компилируется на уровень 3. Потом, через некоторое время метод переходит на уровень 4.
Последнее поле – статус или дополнительное сообщение. В случае нормальной компиляции там пусто. Вообще статусов разных бывает много, но чаще всего можно увидеть два. Made non entrant означает, что в ранее скомпилированный метод теперь запрещён вход. Обычно это происходит, если метод перекомпилировали на новом уровне. Например, здесь forEachRemaining был скомпилирован на уровне 4 и ранее скомпилированная версия на уровне 3 помечена not entrant. При этом если скомпилированный метод уже выполняется, он продолжает выполняться: тех, кто успел зайти, не выгоняют.
Иногда вместо made non entrant можно увидеть статус made zombie, который означает, что метод готов к удалению и будет собран сборщиком мусора.
По логу PrintCompilation мы можем судить, когда началась компиляция и когда в ранее скомпилированный метод входить больше нельзя. Хотелось бы явно видеть ещё, когда свежескомпилированный метод готов к выполнению. Для этого пригодится опция TraceNMethodInstalls. Она разбавит лог вот такими простым и и понятными строчками. В скобках номер уровня tier-компиляции, а дальше полная сигнатура метода.
Эта опция, как и некоторые другие, которые мы используем, считается диагностической и запрещена по умолчанию. Чтобы её использовать, надо дописать ещё –XX:+UnlockDiagnosticVMOptions.
Итак, знаниями вооружились, добавим опций и посмотрим на вывод нашей программы. Вот, что мы видим в районе 5600. Предыдущие сотни итераций было всё спокойно, ничего не компилировалось, а тут компилятор активизировался. Началось всё с перекомпиляции самого главного метода sumTwiceStream на 4-м уровне. До этого он был скомпилирован на уровне 3 простым компилятором C1 с меньшим количеством оптимизаций. Сейчас же дошло дело до компилятора C2, который обычно делает всё лучше и быстрее. Компиляция, как я говорил, идёт в отдельном потоке, а программа продолжает выполняться. Через три итерации компиляция закончилась, старый скомпилированный компилятором C1 sumTwiceStream был помечен как «not entrant», и была установлена новая версия. Но так как итерация 5634 уже вовсю шла, она доработала со старым sumTwiceStream. А вот следующая началась уже с новым, который оказался не быстрее, как планировалось, а в 15 раз медленнее.
Тут мы, кстати, видим ещё один интересный момент — зачем-то сразу после этого компилятором C2 скомпилировалась лямбда, которая отвечает за суммирование. Казалось бы, суммирование мы делали уже миллиарды раз, оно должно было быть давно скомпилировано?
Интересно, почему метод перекомпилировался именно сейчас. Оказывается, среди тысячи параметров HotSpot есть опция, позволяющая управлять тем, сколько запусков метода потребуется для C2-компиляции: это Tier4InvocationThreshold. Tier4, как вы уже знаете, – это внутреннее обозначение C2-компилятора. По умолчанию там 5000, поставьте 4000, и момент истины наступит на 1000 итераций раньше. Остальные 600 с лишним итераций, видимо накопились из различных задержек и не задаются жёстко.
Разумеется, не всегда надо вызвать метод 5000 раз, чтобы случилась C2-компиляция: она может наступить и раньше. Например, другой параметр, который её контролирует, - это Tier4BackEdgeThreshold: суммарное количество обратных переходов в методе, о которых мы говорили. Как раз благодаря ему срабатывал OnStackReplacement в нашем первом примере.
Почему же крутой мегаоптимизирующий компилятор C2 произвёл код, который в 15 раз медленнее, чем то, что было до этого скомпилировано C1? Баг в компиляторе?
Есть подозрение, что проблема как-то связана с инлайнингом. Инлайнинг — это замечательная способность компиляторов встраивать тела одних методов в другие. К примеру, если бы в нашем простом тесте мы умножение вынесли в отдельный метод, даже компилятор C1 догадался бы подставить тело этого метода в основной. Компилятор C2 же использует инлайнинг гораздо более агрессивно, порой встраивая десятки и даже сотни методов друг в друга. Инлайнинг не только позволяет экономить на вызове метода, но и открывает дорогу десяткам других оптимизаций, которые не могут выглянуть за пределы текущего метода.
Как посмотреть, что и куда инлайнится? Разумеется, для этого тоже есть опция JVM — +PrintInlining. Давайте её включим и ещё раз посмотрим.
Ох, страшно-то как.
Это я только маленький кусок привёл для примера. Тут как раз показано, как происходит инлайнинг в forEachRemaining, который скомпилировался в режиме OnStackReplacement где-то в самом-самом начале. Если всё-таки в этом разобраться, можно понять, что заинлайнить удалось вообще всё.
Если совсем страшно, можно в качестве альтернативы использовать такой инструмент JITWatch. К сожалению, у нас не хватит времени, чтобы подробно с ним познакомиться, но вы можете поэкспериментировать сами. Он умеет разбирать лог компиляции HotSpot и в числе прочего рисовать вот такие красивые графики. Вот жёлтые имена методов — это то, что заинлайнилось. Вот наша лямбда, вот суммирование, и можно понять, что в во внутреннем цикле forEachRemaining удалось заинлайнить всё.
Чтобы было ещё понятнее, я нарисовал вот такую картинку. По факту примерно вот так выглядит иерархия вызовов до 5600 итерации. К этому момент C2-компилятор сработал только для forEachRemaining, потому что там было много обратных переходов. Он заинлайнил всё, что можно, и круто соптимизировав всю нижнюю часть, с раскруткой цикла и векторизацией. Всё остальное, включая основной метод sumTwiceStream, скомпилировано пока компилятором C1, который смог только заинлайнить только reduce внутрь sum, а все остальные вызовы остались независимыми методами. Виртуальный вызов C1 редко может заинлайнить. Но это ничего особо не замедляет, потому что время выполнения всей этой цепочки всё равно мало по сравнению с циклом на 10 миллионов итераций. То есть, до 5600-й итерации программа выглядит по факту вот так, и мы измерили производительность вот такого кода.
Теперь посмотрим, что произошло, когда перекомпилировался sumTwiceStream компилятором C2. Опять же на слайде только маленький кусочек лога, по факту про эту перекомпиляцию пишется строчек 100. Мы видим, что C2 играючи инлайнит десятки методов, но в самой самой глубине мы видим неприятную надпись: inlining too deep.
Вот как это выглядит в JITWatch. Там весь метод sumTwiceStream — это огромное дерево, почти сто вызовов удалось заинлайнить внутрь, но вот самые глубокие и самые нужные выводятся чёрным цветом и, если навестись, мы увидим то же самое «Inlining too deep». Просто у JVM есть жёсткий лимит на глубину вызовов, которые в принципе разрешено заинлайнить. По умолчанию этот лимит равен 9 и мы в него упираемся.
Фактически вот какой код мы имеем после итерации 5600 и дальше мы уже измеряем именно его производительность. Заметьте, хотя исходник не менялся, но это по большому счёту уже совсем другая программа, она весьма непохожа на предыдущую. И, к сожалению, гораздо медленнее, потому что теперь основной цикл вызывает на каждой итерации два внешних метода и не может применить другие эффективные оптимизации. Как можно это побороть? Ну правильно, если мы уткнулись в лимит, надо этот лимит увеличить. Для этого, конечно, есть специальная опция JVM – MaxInlineLevel.
Кстати, может некоторые задаются вопросом, откуда я узнал все эти опции. Tier4InvocationThreshold, UseCompiler, UseOnstackReplacement, MaxInlineLevel. Можно, конечно, поискать в интернете, но лучше спросить у самой JVM. Все эти настройки вместе со значениями по умолчанию легко посмотреть, набрав вот такую волшебную строку. Вы получите портянку, в которой почти тысяча опций. Они могут отличаться от версии к версии JVM, поэтому лучше доверять этому списку, чем каким-нибудь статьям в интернете. Все эти опции можно крутить на свой страх и риск. Вообще HotSpot – чрезвычайно настраиваемая штука.
Так что, судя по картинке выше, надо поднять MaxInlineLevel где-то на два. В случае с JMH-бенчмарком на самом деле стек ещё длиннее, потому что включает сам бенчмарк. Перебором я установил, что MaxInlineLevel = 13 хватает. Теперь мы видим, что всё в шоколаде: стрим очень быстр для всех размеров задачи.
Давайте ещё раз глянем на лог по итерациям для миллиона. Справа написано, как было раньше. Вы помните, у нас раньше на третьей итерации всё ломалось. А теперь не ломается, даже наоборот: после C2-перекомпиляции стало чуть быстрее.
По сути дела мы теперь для ста тысяч и для миллиона измерили производительность ещё одной программы, где вообще весь код теста слит в один метод. Для 10 миллионов результаты не поменялись, потому что перекомпилировать не успели. Какая же теперь мораль? Давайте использовать стримы везде, только надо MaxInlineLevel задрать посильнее?
К сожалению, всё не так радужно. Мы упустили одну важную вещь. Внутри реализации стрим-методов обычно написано что-нибудь типа вызвать IntConsumer.accept. Мало ли существует в программе реализаций интерфейса IntConsumer? Откуда JIT знает, какую из них вставить сюда? Неслучайно C2-компиляция отложена на 5000 вызовов. В это время программа не просто работает, а ещё и собирается статистика, как конкретно она работает – это называется type profile. В случае наших тестов собралась такая информация, что в 100% случаев в map-операции использовалась лямбда, умножающая на два, а в reduce-операции использовалось Long::sum. Такие вызовы называются мономорфными: хотя в данном месте потенциально может быть вызвана любая функция, статистика говорит, что всегда вызывалась конкретная, поэтому компилятор предполагает, что и дальше будет то же самое, и инлайнит эту конкретную функцию. Это работает в нашем чистеньком бенчмарке, но вряд ли в реальном приложении мы будем использовать Stream API только в одном месте. По факту мы измерили производительность в идеальных условиях, которые редко достижимы на практике.
Давайте сэмулируем загрязнение профиля в нашем бенчмарке. Добавим ещё один параметр pollute и отдельный метод setup, который выполняется один раз перед каждым бенчмарком. При pollute = 0 мы ничего не делаем, а при 1, 2 и 3 соответствующее количество раз выполняем вот такой бесполезный цикл с разными лямбдами. Заметьте, тут case без break, поэтому, например, для pollute = 3 выполняются все ветки.
Обращаю особое внимание: сами тесты мы никак не меняли. Глобальное состояние программы мы вроде как тоже не меняли. Этот код просто выполнится один раз перед всеми тестами, один раз, и как будто бы никаких побочных эффектов не создаёт. А потом мы делаем всё те же 5 разогревочных итераций и 10 основных.
Тут я уже не привожу результаты традиционной реализации: они от значения pollute не меняются. А вот что происходит со стримами. Одинарный удар в челюсть компилятор переносит с честью: замедление наблюдается, но сравнительно небольшое. В таком случае в профиле два возможных варианта, компилятор инлайнит оба, добавляя проверку типа. Такой вызов называется биморфным. А вот второй удар в челюсть укладывает компилятор на обе лопатки: если вариантов больше двух, такой вызов становится полиморфным и инлайнинг выключается совсем, несмотря на увеличенный MaxInlineLevel. Дальнейшее увеличение pollute жизнь особо хуже не делает, она и так уже испорчена.
Вот какую программу мы получили в этом случае: reduce всё ещё встраивается, но map-функция теперь не инлайнится, а вызывается отдельно, и это поломало всю оптимизацию цикла. Если загрязнить профиль разными reduce-операциями, будет ещё больше отдельных методов. В реальном большом приложении, где активно используются стримы для разных задач, скорее всего будет что-то подобное.
Вообще загрязнение профиля типов — большая головная боль для разработчиков виртуальной машины. Если кому интересно, вот пара багов на тему. Возможно, в девятой джаве или в апдейтах к ней что-нибудь изменится, но пока увы вот так. Так что же, какова мораль? Всё-таки не будем использовать стримы?
На самом деле мы всю дорогу игнорировали одну тривиальную вещь: эту задачу можно решить гораздо быстрее и вообще за константное время на много порядков быстрее. Смысл бороться за 4 миллисекунды, когда задача решается за 3 наносекунды?
Эта задача была слишком искусственной. Дело не в том, что стрим на порядок медленнее, а в том, что при полном инлайнинге простой алгоритм с суммированием и умножением оптимизируется очень круто. Всё, что мы измерили, не имеет отношения к реальным задачам, за которые обычно платят деньги. На реальных задачах стрим редко проигрывает больше, чем вдвое, если только задача не настолько быстрая, чтобы это вообще кого-то волновало. Чаще различие составляет не более десятков процентов и редко является узким местом в производительности. Поэтому смело пользуйтесь Stream API.
Не стоит печалиться, что наша задача слишком искусственная. Полученные нами цифры на практике бесполезны, но зато очень полезны полученные знания. Мы глубоко копнули HotSpot, познакомились с добрым десятком опций, как чисто диагностических (справа), так и влияющих на работу виртуальной машины. Мы немного научились читать и интерпретировать диагностику и познакомились с полезными инструментами — JMH и JITWatch. Мы увидели, что один и тот же исходный Java-код на этапе выполнения может совершенно причудливо меняться в зависимости от того, сколько раз он выполнялся до этого, какой ещё другой код до этого выполнялся, с какими опциями запущена виртуальная машина и так далее. А главное, мы поняли, что взять хороший вольтметр недостаточно, чтобы получить хороший результат. Здесь, как и в физике, правильная постановка эксперимента так же важна, как и правильные приборы. В целом не бойтесь придумывать искусственные задачи: пусть они не имеют отношения к реальному продакшн-коду, зато помогают глубже понять, как работает язык.
Ещё маленькое замечание насчёт опций JVM. Их очень много, почти тысяча, но это не значит, что надо их слепо крутить в продакшне на основании результатов одного бенчмарка, а тем более на основании советов в интернете. Помните, что опции влияют на всё приложение, а значения по умолчанию аккуратно подобраны умными людьми. Будьте осторожны.
Для дальнейшего изучения вопросов, связанных с вызовом методов и инлайнингом в JVM, почитайте статью Алексея Шипилёва «The Black Magic of Java Method Dispatch». Она весьма сурова и рассчитана на продвинутого читателя, но очень интересна. Кому это покажется слишком сурово, можете посмотреть выступление разработчика HotSpot Владимира Иванова «Динамическая JIT-компиляция в JVM», оно попроще будет. Насчёт того как правильно замерять производительность Java-кода: если вы ещё не слушали выступление Алексея с прошлого Joker’а «Как два таймстампа прочитать», обязательно посмотрите или почитайте текстовую версию на английском. Относительно же странных эффектов, рассмотренных в моём докладе, стоит взглянуть на ответ Пола Сандоза, одного из разработчиков стрим API. В приведённом вопросе на StackOverflow разбирается похожий случай.
Всем спасибо за внимание, быстрых программ, добра и обнимашек!