Статьи

Микробенчмаркинг приходит к Java 9

b05673_mockupcover_normal_ Я не писал здесь статьи в течение нескольких месяцев, и это также будет продолжаться с этим исключением. Я планирую вернуться писать в марте следующего года. Объяснение в конце этой статьи. Подождите! Не совсем в конце, потому что вы можете просто прокрутить вниз. Это где-то ближе к концу статьи. Просто читай дальше!

Три года назад я писал о том, как компилятор Java оптимизирует код, который он выполняет. Или, скорее, как это не делает javac , а JIT делает то же самое. Я сделал некоторые тесты, некоторые действительно плохие, как упоминал Эско Луонтола . Эти тесты должны были показать, что JIT оптимизируют еще до того, как он сможет собрать значительные статистические данные о выполнении кода.

Статья была создана в январе 2013 года. Самая первая загрузка исходного кода JMH (Java Microbenchmark Harness) произошла два месяца спустя. С тех пор жгут много развился и в следующем году он станет частью следующего выпуска Java. У меня есть контракт на написание книги о Java 9, и ее глава 5 должна охватывать, помимо прочего, возможности микробенчмаркинга Java 9. Это хорошая причина, чтобы начать играть с JMH.

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

Microbenchmarking

Микробенчмаркинг измеряет производительность небольшого фрагмента кода. Он редко используется, и прежде чем начинать делать микробенчмарки для реальной коммерческой среды, мы должны дважды подумать. Помните, что преждевременная оптимизация является корнем всего зла. Некоторые разработчики создали обобщение этого утверждения, сказав, что оптимизация сама по себе является корнем всего зла, что может быть правдой. Особенно, если мы имеем в виду микробенчмаркинг.

Микробенчмаркинг — это заманчивый инструмент для оптимизации чего-то небольшого, не зная, стоит ли оптимизировать этот код. Когда у нас огромное приложение с несколькими модулями, работающими на нескольких серверах, как мы можем быть уверены, что улучшение какой-то специальной части приложения кардинально повысит производительность? Окупится ли это увеличением дохода, который принесет такую ​​большую прибыль, что покроет затраты, которые мы потратили на тестирование производительности и разработку? Я не хочу сказать, что вы не можете знать это, но только потому, что такое заявление будет слишком широким. С точки зрения статистики почти уверен, что такая оптимизация, в том числе микробенчмаркинг, большую часть времени не пройдет. Это будет больно, вы просто можете не заметить этого или даже наслаждаться этим, но это совсем другая история.

Когда использовать микробенчмаркинг? Я вижу три области:

  1. Вы пишете статью о микробенчмаркинге.
  2. Вы определили сегмент кода, который потребляет большую часть ресурсов в вашем приложении, и улучшение может быть проверено микробенчмарками.
  3. Вы не можете идентифицировать сегмент кода, который будет поглощать большую часть ресурсов в приложении, но вы подозреваете это.

Первая область — это шутка. Или нет: вы можете поиграть с микробенчмаркингом, чтобы понять, как он работает, а затем понять, как работает код Java, что работает быстро, а что нет. В прошлом году Такипи опубликовал статью, в которой пытался измерить скорость лямбд. Прочитайте это, очень хорошая статья и ясно демонстрирует главное преимущество ведения блога перед написанием чего-то для печати. Читатели прокомментировали и указали на ошибки, и они были исправлены в статье.

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

Ловушки

Каковы подводные камни микробенчмаркинг? Бенчмаркинг проводится как эксперимент. Первыми программами, которые я написал, был код калькулятора TI, и я мог просто посчитать количество шагов, которые программа сделала, чтобы вычислить два больших (10 разрядов) простых чисел. Даже в то время я использовал старый русский секундомер для измерения времени, ленивого для подсчета количества шагов. Эксперимент и измерение было проще.

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

Какая самая большая проблема измерений? Мы заинтересованы в чем-то, скажем X, и мы обычно не можем измерить это. Таким образом, мы измеряем вместо этого Y и надеемся, что значения Y и X связаны вместе. Мы хотим измерить длину комнаты, но вместо этого мы измеряем время, необходимое для прохождения лазерного луча от одного конца к другому. В этом случае длина X и время Y сильно связаны. Много раз X и Y только коррелируют более или менее. В большинстве случаев, когда люди проводят измерения, значения X и Y не имеют никакого отношения друг к другу. Тем не менее люди вкладывают свои деньги и многое другое в решения, подкрепленные такими измерениями. Подумайте о политических выборах в качестве примера.

Микробенчмаркинг ничем не отличается. Это вряд ли когда-либо сделано хорошо. Если вас интересуют подробности и возможные подводные камни, у Алексея Шипилева есть хорошее одночасовое видео. Первый вопрос — как измерить время выполнения. Небольшой код выполняется короткое время, и System.currentTimeMillis() может просто возвращать одно и то же значение, когда измерение начинается и когда оно заканчивается, потому что мы все еще находимся в той же миллисекунде. Даже если выполнение составляет 10 мс, погрешность измерения по-прежнему составляет не менее 10% исключительно из-за квантования времени, которое мы измеряем. К счастью, есть System.nanoTime() . Мы счастливы, Винсент?

На самом деле, нет. nanoTime() возвращает текущее значение источника времени работающей виртуальной машины Java с высоким разрешением, в наносекундах, как указано в документации. Что такое «текущий»? Когда был сделан вызов? Или когда его вернули? Или когда-нибудь между? Выберите тот, который вы хотите, и вы все равно можете потерпеть неудачу. Это текущее значение могло быть таким же в течение последних 1000 нс, что должны гарантировать все реализации Java.

И еще одно предупреждение перед использованием nanoTime() из документации: Различия в последовательных вызовах, которые охватывают более чем приблизительно 292 года (263 наносекунды), не будут правильно вычислять истекшее время из-за числового переполнения.

292 года? В самом деле?

Есть и другие проблемы. При запуске кода Java первые несколько тысяч выполнений кода будут интерпретироваться или выполняться без оптимизации во время выполнения. JIT имеет преимущество перед компиляторами статически скомпилированных языков, таких как Swift, C, C ++ или Golang, в том, что он может собирать информацию времени выполнения при выполнении кода, и когда он видит, что компиляция, которую он выполнял в прошлый раз, могла бы быть лучше на основе последних во время выполнения статистика снова компилирует код. То же самое может быть верно для сборки мусора, которая также пытается использовать статистику для настройки своих рабочих параметров. Благодаря этому хорошо написанные серверные приложения со временем получают небольшую производительность. Они запускаются немного медленнее, а потом просто становятся быстрее. Если вы перезапустите сервер, вся итерация начнется снова.

Если вы делаете микро тесты, вы должны позаботиться об этом. Вы хотите измерить производительность приложения во время прогрева или как оно реально работает во время работы?

Решением является микропроцессорный жгут, который пытается учесть все эти предостережения. Тот, который попадает в Java 9 — это JMH.

Что такое JMH?

«JMH — это система Java для создания, запуска и анализа нано / микро / милли / макро тестов, написанных на Java и других языках, предназначенных для JVM». (цитата с официального сайта JMH )

Вы можете запустить jmh как отдельный проект, независимый от фактического проекта, который вы измеряете, или вы можете просто сохранить код измерения в отдельном каталоге. Жгут будет скомпилирован с файлами производственного класса и выполнит тест. Самым простым способом, как я вижу, является использование плагина Gradle для выполнения JMH. Вы сохраняете код теста в каталоге с именем jmh (на том же уровне, что и main и test ) и создаете main который может запустить тест.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.RunnerException;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;
import java.io.IOException;
 
public class MicroBenchmark {
 
    public static void main(String... args) throws IOException, RunnerException {
        Options opt = new OptionsBuilder()
                .include(MicroBenchmark.class.getSimpleName())
                .forks(1)
                .build();
 
        new Runner(opt).run();
    }

Есть хороший интерфейс для конфигурации и класс Runner который может выполнять тесты.

Немного играя

В книге Java 9 Programming By Example одним из примеров является игра Mastermind . Глава 5 посвящена параллельному решению игры, чтобы ускорить догадки. (Если вы не знаете игру, пожалуйста, прочтите ее в Википедии, я не хочу ее здесь объяснять, но она понадобится вам, чтобы понять следующее.)

Нормальное гадание просто. Здесь скрыт секрет. Секрет в четырех колышках четырех разных цветов из 6 цветов. Когда мы угадаем, мы берем возможные вариации цвета один за другим и задаем вопрос таблице: если этот выбор является секретом, все ответы верны? Другими словами: может ли это предположение быть скрытым или есть какое-то противоречие в ответах на некоторые предыдущие ответы? Если это предположение может быть секретом, мы попробуем поставить колышки на стол. Ответ может быть 4/0 (аллулия) или что-то еще. В последнем случае мы продолжаем поиск. Таким образом, таблица из 6 цветов и 4 столбцов может быть решена за пять шагов.

Для простоты и наглядности мы 01234456789 цвета цифрами, например 01234456789 (у нас десять цветов в эталонном тесте jmh, поскольку 6 цветов просто недостаточно) и 6 колышков. Секрет, который мы используем, это 987654 потому что это последнее предположение, когда мы идем от 123456 , 123457 и так далее.

Когда я впервые написал эту игру в августе 1983 года на шведском школьном компьютере (ABC80) на языке BASIC, каждое предположение заняло от 20 до 30 секунд на процессоре z80, работающем на 40 МГц 6 цветов, 4 позиции. Сегодня мой MacBook Pro может играть всю игру, используя одну нить примерно 7 раз в секунду, используя 10 цветов и 6 колышков. Но этого недостаточно, когда у меня в машине 4 процессора, поддерживающих 8 параллельных потоков.

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

Это действительно ускоряет догадки? Это JMH здесь для.

Для запуска теста нам нужен код, который на самом деле выполняет игру

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
@State(Scope.Benchmark)
    public static class ThreadsAndQueueSizes {
        @Param(value = {"1", "4", "8", "16", "32"})
        String nrThreads;
        @Param(value = { "1", "10", "100", "1000000"})
        String queueSize;
 
    }
 
    @Benchmark
    @Fork(1)
    public void playParallel(ThreadsAndQueueSizes t3qs) throws InterruptedException {
        int nrThreads = Integer.valueOf(t3qs.nrThreads);
        int queueSize = Integer.valueOf(t3qs.queueSize);
        new ParallelGamePlayer(nrThreads, queueSize).play();
    }
 
    @Benchmark
    @Fork(1)
    public void playSimple(){
        new SimpleGamePlayer().play();
    }

Платформа JMH будет выполнять код несколько раз, измеряя время выполнения с несколькими параметрами. Метод playParallel будет выполнен для запуска алгоритма для 1, 4, 5, 10 и 32 потоков, каждый из которых имеет максимальную длину очереди 1, 10, 100 и миллион. Когда очередь заполнена, отдельные пользователи останавливают свои предположения, пока основной поток не вытянет хотя бы одно предположение из очереди.

Я подозревал, что если у нас много потоков и мы не ограничиваем длину очереди, то рабочие потоки будут заполнять очередь начальными догадками, основанными только на пустой таблице и, следовательно, не приносящими большой пользы. Что мы видим после почти 15 минут казни?

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
Benchmark                    (nrThreads)  (queueSize)   Mode  Cnt   Score   Error  Units
MicroBenchmark.playParallel            1            1  thrpt   20   6.871 ± 0.720  ops/s
MicroBenchmark.playParallel            1           10  thrpt   20   7.481 ± 0.463  ops/s
MicroBenchmark.playParallel            1          100  thrpt   20   7.491 ± 0.577  ops/s
MicroBenchmark.playParallel            1      1000000  thrpt   20   7.667 ± 0.110  ops/s
MicroBenchmark.playParallel            4            1  thrpt   20  13.786 ± 0.260  ops/s
MicroBenchmark.playParallel            4           10  thrpt   20  13.407 ± 0.517  ops/s
MicroBenchmark.playParallel            4          100  thrpt   20  13.251 ± 0.296  ops/s
MicroBenchmark.playParallel            4      1000000  thrpt   20  11.829 ± 0.232  ops/s
MicroBenchmark.playParallel            8            1  thrpt   20  14.030 ± 0.252  ops/s
MicroBenchmark.playParallel            8           10  thrpt   20  13.565 ± 0.345  ops/s
MicroBenchmark.playParallel            8          100  thrpt   20  12.944 ± 0.265  ops/s
MicroBenchmark.playParallel            8      1000000  thrpt   20  10.870 ± 0.388  ops/s
MicroBenchmark.playParallel           16            1  thrpt   20  16.698 ± 0.364  ops/s
MicroBenchmark.playParallel           16           10  thrpt   20  16.726 ± 0.288  ops/s
MicroBenchmark.playParallel           16          100  thrpt   20  16.662 ± 0.202  ops/s
MicroBenchmark.playParallel           16      1000000  thrpt   20  10.139 ± 0.783  ops/s
MicroBenchmark.playParallel           32            1  thrpt   20  16.109 ± 0.472  ops/s
MicroBenchmark.playParallel           32           10  thrpt   20  16.598 ± 0.415  ops/s
MicroBenchmark.playParallel           32          100  thrpt   20  15.883 ± 0.454  ops/s
MicroBenchmark.playParallel           32      1000000  thrpt   20   6.103 ± 0.867  ops/s
MicroBenchmark.playSimple            N/A          N/A  thrpt   20   6.354 ± 0.200  ops/s

(В баллах чем больше, тем лучше.) Это показывает, что лучшую производительность мы получим, если запустим 16 потоков и несколько ограничим длину очереди. Запуск параллельного алгоритма в одном потоке (mater и worker) несколько медленнее, чем реализация в одном потоке. Кажется, все в порядке: у нас есть накладные расходы на запуск нового потока и связь между потоками. Максимальная производительность у нас составляет около 16 потоков. Поскольку у нас в этой машине может быть 8 ядер, мы ожидаем, что примерно 8.

Что произойдет, если мы заменим стандартный секрет 987654 (который через некоторое время скучен даже для процессора) чем-то случайным?

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
Benchmark                    (nrThreads)  (queueSize)   Mode  Cnt   Score   Error  Units
MicroBenchmark.playParallel            1            1  thrpt   20  12.141 ± 1.385  ops/s
MicroBenchmark.playParallel            1           10  thrpt   20  12.522 ± 1.496  ops/s
MicroBenchmark.playParallel            1          100  thrpt   20  12.516 ± 1.712  ops/s
MicroBenchmark.playParallel            1      1000000  thrpt   20  11.930 ± 1.188  ops/s
MicroBenchmark.playParallel            4            1  thrpt   20  19.412 ± 0.877  ops/s
MicroBenchmark.playParallel            4           10  thrpt   20  17.989 ± 1.248  ops/s
MicroBenchmark.playParallel            4          100  thrpt   20  16.826 ± 1.703  ops/s
MicroBenchmark.playParallel            4      1000000  thrpt   20  15.814 ± 0.697  ops/s
MicroBenchmark.playParallel            8            1  thrpt   20  19.733 ± 0.687  ops/s
MicroBenchmark.playParallel            8           10  thrpt   20  19.356 ± 1.004  ops/s
MicroBenchmark.playParallel            8          100  thrpt   20  19.571 ± 0.542  ops/s
MicroBenchmark.playParallel            8      1000000  thrpt   20  12.640 ± 0.694  ops/s
MicroBenchmark.playParallel           16            1  thrpt   20  16.527 ± 0.372  ops/s
MicroBenchmark.playParallel           16           10  thrpt   20  19.021 ± 0.475  ops/s
MicroBenchmark.playParallel           16          100  thrpt   20  18.465 ± 0.504  ops/s
MicroBenchmark.playParallel           16      1000000  thrpt   20  10.220 ± 1.043  ops/s
MicroBenchmark.playParallel           32            1  thrpt   20  17.816 ± 0.468  ops/s
MicroBenchmark.playParallel           32           10  thrpt   20  17.555 ± 0.465  ops/s
MicroBenchmark.playParallel           32          100  thrpt   20  17.236 ± 0.605  ops/s
MicroBenchmark.playParallel           32      1000000  thrpt   20   6.861 ± 1.017  ops/s

Производительность увеличивается, так как нам не нужно идти, хотя все возможные варианты. В случае одного потока увеличение в два раза. В случае нескольких потоков выигрыш не так уж и велик. И обратите внимание, что это не ускоряет сам код, а только более реалистично измеряет статистические случайные секреты. Что мы также можем видеть, что усиление 16 потоков по сравнению с 8 потоками уже не является значительным. Это важно только тогда, когда мы выбираем секрет, который подходит к концу изменений. Почему? Из того, что вы видели здесь, и из исходного кода, доступного в GitHub, вы можете дать ответ на это.

Резюме

Книгу Java 9 Programming By Example планируется выпустить в феврале 2017 года. Но так как мы живем в мире открытого исходного кода, вы можете получить доступ, контролируемый издателем, к 1.xx-SNAPSHOT . Теперь я рассказал вам предварительный URL-адрес GitHub, который я использую при разработке кода для книги, и вы также можете предварительно заказать электронную книгу и оставить отзыв, помогая мне создать лучшую книгу.