Перейти к основному содержимому

Микробенчмаркинг с Java

· 6 мин. чтения

1. Введение

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

Проще говоря, JMH заботится о таких вещах, как прогрев JVM и пути оптимизации кода, максимально упрощая бенчмаркинг.

2. Начало работы

Для начала мы можем продолжить работу с Java 8 и просто определить зависимости:

<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-core</artifactId>
<version>1.33</version>
</dependency>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>1.33</version>
</dependency>

Последние версии JMH Core и JMH Annotation Processor можно найти в Maven Central.

Затем создайте простой тест, используя аннотацию @Benchmark (в любом общедоступном классе):

@Benchmark
public void init() {
// Do nothing
}

Затем добавляем основной класс, запускающий процесс бенчмаркинга:

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

Теперь запуск BenchmarkRunner запустит наш, возможно, несколько бесполезный тест. После завершения прогона представляется сводная таблица:

# Run complete. Total time: 00:06:45
Benchmark Mode Cnt Score Error Units
BenchMark.init thrpt 200 3099210741.962 ± 17510507.589 ops/s

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

JMH поддерживает несколько возможных тестов: пропускная способность, среднее время, SampleTime и SingleShotTime . Их можно настроить с помощью аннотации @BenchmarkMode :

@Benchmark
@BenchmarkMode(Mode.AverageTime)
public void init() {
// Do nothing
}

В результирующей таблице будет метрика среднего времени (вместо пропускной способности):

# Run complete. Total time: 00:00:40
Benchmark Mode Cnt Score Error Units
BenchMark.init avgt 20 ≈ 10⁻⁹ s/op

4. Настройка разминки и выполнения

Используя аннотацию @Fork , мы можем настроить, как происходит выполнение теста: параметр value определяет, сколько раз будет выполняться тест, а параметр warmup определяет, сколько раз тест будет выполняться в пробном режиме, прежде чем, например, будут собраны результаты. :

@Benchmark
@Fork(value = 1, warmups = 2)
@BenchmarkMode(Mode.Throughput)
public void init() {
// Do nothing
}

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

Кроме того, аннотацию @Warmup можно использовать для управления количеством итераций прогрева. Например, @Warmup(iterations = 5) сообщает JMH, что будет достаточно пяти итераций прогрева, а не 20 по умолчанию.

5. Состояние

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

Мы можем исследовать влияние на производительность с помощью объекта State :

@State(Scope.Benchmark)
public class ExecutionPlan {

@Param({ "100", "200", "300", "500", "1000" })
public int iterations;

public Hasher murmur3;

public String password = "4v3rys3kur3p455w0rd";

@Setup(Level.Invocation)
public void setUp() {
murmur3 = Hashing.murmur3_128().newHasher();
}
}

Тогда наш эталонный метод будет выглядеть так:

@Fork(value = 1, warmups = 1)
@Benchmark
@BenchmarkMode(Mode.Throughput)
public void benchMurmur3_128(ExecutionPlan plan) {

for (int i = plan.iterations; i > 0; i--) {
plan.murmur3.putString(plan.password, Charset.defaultCharset());
}

plan.murmur3.hash();
}

Здесь итерации поля будут заполнены соответствующими значениями из аннотации @Param с помощью JMH , когда он будет передан методу эталонного теста. Аннотированный метод @Setup вызывается перед каждым вызовом эталонного теста и создает новый хэшер , обеспечивающий изоляцию.

Когда выполнение будет завершено, мы получим результат, аналогичный приведенному ниже:

# Run complete. Total time: 00:06:47

Benchmark (iterations) Mode Cnt Score Error Units
BenchMark.benchMurmur3_128 100 thrpt 20 92463.622 ± 1672.227 ops/s
BenchMark.benchMurmur3_128 200 thrpt 20 39737.532 ± 5294.200 ops/s
BenchMark.benchMurmur3_128 300 thrpt 20 30381.144 ± 614.500 ops/s
BenchMark.benchMurmur3_128 500 thrpt 20 18315.211 ± 222.534 ops/s
BenchMark.benchMurmur3_128 1000 thrpt 20 8960.008 ± 658.524 ops/s

6. Устранение мертвого кода

При проведении микробенчмарков очень важно помнить об оптимизации . В противном случае они могут сильно ввести в заблуждение результаты тестов.

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

@Benchmark
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@BenchmarkMode(Mode.AverageTime)
public void doNothing() {
}

@Benchmark
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@BenchmarkMode(Mode.AverageTime)
public void objectCreation() {
new Object();
}

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

Benchmark                 Mode  Cnt  Score   Error  Units
BenchMark.doNothing avgt 40 0.609 ± 0.006 ns/op
BenchMark.objectCreation avgt 40 0.613 ± 0.007 ns/op

Видимо найти место в TLAB , создать и инициализировать объект почти бесплатно! Просто взглянув на эти цифры, мы должны понять, что здесь что-то не совсем сходится.

Здесь мы являемся жертвой удаления мертвого кода . Компиляторы очень хорошо оптимизируют избыточный код. Собственно говоря, JIT-компилятор именно это и сделал.

Чтобы предотвратить эту оптимизацию, мы должны как-то обмануть компилятор и заставить его думать, что код используется каким-то другим компонентом. Один из способов добиться этого — просто вернуть созданный объект:

@Benchmark
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@BenchmarkMode(Mode.AverageTime)
public Object pillarsOfCreation() {
return new Object();
}

Кроме того, мы можем позволить Blackhole поглотить его:

@Benchmark
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@BenchmarkMode(Mode.AverageTime)
public void blackHole(Blackhole blackhole) {
blackhole.consume(new Object());
}

Использование Blackhole объекта — это способ убедить JIT-компилятор не применять оптимизацию устранения мертвого кода . В любом случае, если мы снова запустим эти тесты, цифры будут иметь больше смысла:

Benchmark                    Mode  Cnt  Score   Error  Units
BenchMark.blackHole avgt 20 4.126 ± 0.173 ns/op
BenchMark.doNothing avgt 20 0.639 ± 0.012 ns/op
BenchMark.objectCreation avgt 20 0.635 ± 0.011 ns/op
BenchMark.pillarsOfCreation avgt 20 4.061 ± 0.037 ns/op

7. Постоянное складывание

Рассмотрим еще один пример:

@Benchmark
public double foldedLog() {
int x = 8;

return Math.log(x);
}

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

@Benchmark
public double foldedLog() {
return 2.0794415416798357;
}

Эта форма частичного вычисления называется сворачиванием констант . В этом случае сворачивание констант полностью исключает вызов Math.log , в котором и заключался весь смысл теста.

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

@State(Scope.Benchmark)
public static class Log {
public int x = 8;
}

@Benchmark
public double log(Log input) {
return Math.log(input.x);
}

Если мы запустим эти тесты друг против друга:

Benchmark             Mode  Cnt          Score          Error  Units
BenchMark.foldedLog thrpt 20 449313097.433 ± 11850214.900 ops/s
BenchMark.log thrpt 20 35317997.064 ± 604370.461 ops/s

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

8. Заключение

В этом учебном пособии основное внимание уделялось и демонстрировалась система микротестирования Java.

Как всегда, примеры кода можно найти на GitHub .