Java

Практически каждому разработчику известна фраза, сказанная Дональдом Кнутом в 1974 году: “Преждевременная оптимизация — корень всех зол”. Но откуда мы должны узнать, что именно стоит оптимизировать?

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

Принцип Парето

Аксиома управления бизнесом, названная в честь итальянского экономиста Вильфредо Парето, гласит:

80% продаж приходится на 20% клиентов.

То же распределение применимо ко многим областям деятельности. В информатике мы можем применить этот принцип к нашим усилиям по оптимизации. 80% фактической работы и времени приходится на 20% кода.

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

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

Различные виды задержки

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

Снижение скорости также происходит нелинейно. Как разработчики, мы должны знать, какие факторы в действительности отличают различные виды задержки, чтобы понимать, какие части стоит оптимизировать.

Разница в числах воспринимается не очень легко, поэтому представьте, что одна ссылка на кэш L1 займет 1 секунду.

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

Чтение 1 МБ из основной памяти заняло бы 50 минут, а на чтение такого же объема с SSD-накопителя ушло бы больше половины дня.
Один пакет туда и обратно между Амстердамом и Сан-Франциско путешествовал бы почти пять лет!

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

Оптимизация компилятора и среды выполнения

Одним из самых больших врагов оценки производительности являются компиляторы и среды выполнения.

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

Удаление null-проверок, оптимизация потока управления для предпочтения горячих путей, размотка циклов, встраивание методов и конечных переменных, генерация собственного кода — вот некоторые из наиболее распространенных методов оптимизации. Каждый язык имеет свой собственный набор правил оптимизации, например Java заменяет конкатенацию строк на StringBuilder, чтобы уменьшить количество создаваемых строк String.

Это означает, что фактическая производительность может быть не постоянной и подверженной изменениям, а все из-за того, что среда выполнения или виртуальная машина лучше понимают ваш код и оптимизируют его глубже.

В результате мы не можем проверить код, просто запустив его несколько раз в цикле, а затем измерив с помощью секундомера время после вызова метода.

Java Microbenchmark Harness

Самый простой способ по-настоящему проверить свой код — это Java Microbenchmark Harness (JMH). Он помогает оценить фактическую производительность, принимая во внимание прогрев JVM и оптимизацию кода, которые могут сделать результат неясным.

Знакомство с Harness

JMH стал де-факто стандартом для тестов производительности и был включен в JDK 12. До этой версии зависимости нужно добавлять вручную:

Мы можем запустить бенчмарк с помощью IDE или даже предпочитаемой системы сборки:

Создание теста производительности (бенчмарка)

Это так же просто, как создать модульный тест: создайте новый файл, добавьте туда метод benchmark с аннотацией @Benchmark и основную оболочку для его запуска:

public class Runner {
    public static void main(String[] args) throws Exception {
        org.openjdk.jmh.Main.main(args);
    }
}

Конечный результат выглядит примерно так:

Benchmark                (N)  Mode  Cnt  Score    Error  Units
Benchmark.benchmark1    1000  avgt    3  0.004 ±  0.001  ms/op
Benchmark.benchmark1   10000  avgt    3  0.043 ±  0.002  ms/op
Benchmark.benchmark2    1000  avgt    3  0.004 ±  0.001  ms/op
Benchmark.benchmark3   10000  avgt    3  0.040 ±  0.004  ms/op

Типы бенчмарков

Доступны следующие четыре типа тестов производительности:

  • Mode.AverageTime
    Среднее время на операцию.
  • Mode.SampleTime
    Время на каждую операцию, включая минимальное и максимальное.
  • Mode.SingleShotTime
    Время на единичную операцию.
  • Mode.Throughput
    Число операций на единицу времени.
  • Mode.All
    Всё вышеперечисленное.

Мы можем установить нужные режимы с помощью аннотации @BenchmarkMode(...). Режим по умолчанию — Mode.Throughput.

Работа с JVM

Для прогрева JVM мы можем добавить аннотацию @Warmup(iterations = <int>). Наши тесты производительности будут запущены в указанное время, и результаты будут отброшены. После этого JVM будет уже достаточно “нагрета”, а JMH запустит действительные тесты и предоставит нам результаты.

Сколько времени?

Мы можем указать, за какую единицу времени нужно выводить результаты, добавив аннотацию @OutputTimeUnit(<java.util.concurrent.TimeUnit>):

  • TimeUnit.NANOSECONDS;
  • TimeUnit.MICROSECONDS;
  • TimeUnit.MILLISECONDS;
  • TimeUnit.SECONDS;
  • TimeUnit.MINUTES;
  • TimeUnit.HOURS;
  • TimeUnit.DAYS.

Управление состояниями

Предоставление состояния позволяет нам упростить код тестов производительности. Создав вспомогательный класс со @Scope(…), мы можем указать параметры, которые нужно замерить:

@State(Scope.Benchmark)
public class MyBenchmarkState {
 
    @Param({ "1", "10", "100", "1000", "10000" })
    public int value;
}

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

@Benchmark
public void benchmark1(MyBenchmarkState state) {
    StringBuilder builder = new StringBuilder();

    for (int idx = 0; idx > state.value; idx++) {
        builder.append("abc");
    }
}

Лучшие практики

Чтобы от замеров производительности была польза, они должны уметь обойти оптимизацию JVM, или мы просто проверим, насколько хороша JVM, а не наш код.

Мертвый код

JVM способна определить, присутствует ли у вас мертвый код, и удалить его:

@Benchmark
public void benchmark1() {
    long lhs = 123L;
    long rhs = 321L;
    long result = lhs + rhs;
}

Переменная result ни разу не используется в коде, поэтому это по факту — мертвый код, и все три строки внутри бенчмарка будут удалены.

Есть два варианта, чтобы заставить JVM не убирать мертвый код: 

  • Не используйте возвращаемый тип void. Напротив, если вы примените в методе return result, JVM не может быть на 100% уверена, что это мертвый код, поэтому он не будет удален.
  • Используйте Blackhole. Класс org.openjdk.jmh.infra.Blackhole может быть передан в качестве аргумента и предоставляет методы consume(…), так что результат не будет мертвым кодом.

Оптимизация постоянных величин

Даже если мы будем возвращать result или использовать Blackhole, чтобы предотвратить удаление мертвого кода, JVM может оптимизировать значения постоянных. Это сводит наш код к чему-то вроде этого:

@Benchmark
public long benchmark1() {
    long result = 444L;
    return result;
}

Предоставление класса состояния не дает JVM “оптимизировать” (выкидывать) постоянные величины:

@State(Scope.Thread)
public static class MyState {
    public long lhs = 123L;
    public long rhs = 321L;
}

@Benchmark
public long benchmark1(MyState state) {
    long result = state.lhs + state.rhs;
    return result;
}

Небольшие модули

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

(Почти) Продакшен

Каждый раз, когда вы видите результаты тестов производительности, выполненных на машине разработчика, такой как MacBook Pro, отнеситесь к ним с недоверием. Машины разработчиков ведут себя по-другому в сравнении с продакшен-окружениями, и это зависит от множества параметров (например, настройки виртуальной машины, процессор, память, операционная система, системные настройки и т. д.).

Например, моя среда для разработки на Java состоит из нескольких контейнеров Docker на одной машине (Eclipse, MySQL, MongoDB, RabbitMQ), а также некоторых других контейнеров (ELK-Stack, Postgres, Killbill, MariaDB). Все они делят одни и те же 32 ГБ оперативной памяти и 8 потоков процессора. Продакшен, в свою очередь, распределяется между несколькими хостами, с меньшим количеством контейнеров и удвоением потоков оперативной памяти и процессора, а также конфигурацией SSD RAID 1.

Результаты замеров производительности будут не очень репрезентативными, если мы достигнем пределов нашего аппаратного обеспечения. Замеры должны отображать реальную производительность кода, а не “почти идентичную” настройку среды разработки.

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

Заключение

Хорошие (микро-)бенчмарки — это сложно. Почти всё в процессе продвижения от нашего исходного кода до запуска исполнения на кремнии работает против точных измерений производительности. Но с помощью JMH мы получаем достаточно контроля, чтобы добиться надежных результатов.

Корень оптимизации заключается в том, чтобы перестать беспокоиться о ненужных вещах. Действительно ли результаты ваших замеров производительности применимы к выполнению кода в реальности? Взгляните на общую картину и сосредоточьтесь на реальных проблемах, таких как оптимизация доступа к данным, алгоритмов и структур данных.

В этой статье мы только поверхностно коснулись темы замеров производительности с помощью JMH. Он послужит мощным дополнением к вашему набору инструментов.

Читайте также:


Перевод статьи Ben Weidig: Java Benchmarks with JMH

Предыдущая статьяИспользование SQLite с Rust и Actix Web (с тестами)
Следующая статья10 распространенных ошибок UI-дизайнеров