Перейти к содержанию

Микротестирование в Java

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

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

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

Для начала определим зависимости:

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

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

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

@Benchmark
public void init() {
    // Ничего не делать
}

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

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

Типы тестов

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

@Benchmark
@BenchmarkMode(Mode.AverageTime)
public void init() {
    // Ничего не делать
}

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

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

Настройка подготовки и выполнения

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

@Benchmark
@Fork(value = 1, warmups = 2)
@BenchmarkMode(Mode.Throughput)
public void init() {
    // Ничего не делать
}

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

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

Состояние

Теперь рассмотрим, как можно выполнить менее тривиальную и более показательную задачу тестирования алгоритма хеширования с использованием 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();
}

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

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

# 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

Удаление мертвого кода

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

Рассмотрим конкретный пример:

@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

Свертка констант

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

@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

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

Заключение

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

Примеры кода можно найти на GitHub.

Оригинал