Измеряем скорость кода Java правильно (используя JMH)

  • Tutorial

Привет, Хабр!


Это вводная статья про то, как следует делать тесты производительности на JVM языках (java, kotlin, scala и тд.). Она полезна для случая, когда требуется в цифрах показать изменение производительности от использования определенного алгоритма.


Все примеры приведены на языке kotlin и для системы сборки gradle. Исходный код проекта доступен на github.


КДВП


Подготовка


JMH


В первую очередь остановимся на основной части наших замеров — использовании JMH. Java Microbenchmark Harness — набор библиотек для тестирования производительности небольших функций (то есть тех, где пауза GC увеличивает время работы в разы).


Перед запуском теста JMH перекомпилирует код, так как:


  1. Для уменьшения погрешности вычисления времени работы функции необходимо запустить её N раз, подсчитать общее время работы, а потом поделить его на N.
  2. Для этого требуется обернуть запуск в виде цикла и вызова необходимого метода. Однако в этом случае на время работы функции повлияет сам цикл, а также сам вызов замеряемой функции. А потому вместо цикла будет вставлен непосредственно код вызова функции, без reflection или генерации методов в runtime.

После переделки байткода тестирование можно запустить командой вида java -jar benchmarks.jar, так как все необходимые компоненты уже будут запакованы в один jar файл.


JMH Gradle Plugin


Как понятно из описания выше, для тестирования производительности кода недостаточно просто добавить необходимые библиотеки в classpath и запустить тесты в стиле JUnit. А потому, если мы хотим делать дело, а не разбираться в особенности написания билд скриптов, нам не обойтись без плагина к maven/gradle. Для новых проектов преимущество остается за gradle, потому выбираем его.


Для JMH есть полуофициальный плагин для gradle — jmh-gradle-plugin. Добавляем его в проект:


buildscript {
    repositories {
        mavenCentral()

        maven {
            url "https://plugins.gradle.org/m2/"
        }
    }
    dependencies {
        classpath "me.champeau.gradle:jmh-gradle-plugin:$jmh_gradle_plugin_version"
    }
}

apply plugin: "me.champeau.gradle.jmh"

Плагин автоматом создаст новый source set (это "набор файлов и ресурсов, которые должны компилироваться и запускаться вместе", прочитать можно или статью на хабре за авторством svartalfar, или же в официальной документации gradle). jmh source set автоматически ссылается на main, то есть получаем короткий алгоритм работы:


  1. Код, который мы будем изменять, пишем в стандартном main source set, там же, где и всегда.
  2. Код с настройкой и прогревом тестов пишем в отдельном source set. Именно его byte code и будет перезаписываться, сюда плагин добавит необходимые зависимости, в которых есть определения аннотация и тд.

Получаем следующую иерархию каталогов:


  • src
    • jmh / kotlin/ <Имя java пакета> / <код, запускающий тесты (и аннотированный JMH аттрибутами)>
    • main / kotlin / <Имя java пакета> / <код для тестирования>

Или как это выглядит в IntelliJ Idea:


JMH Source Set в IntelliJ Idea


В итоге, после настройки проекта, можно запускать тесты простым вызовом .\gradlew.bat jmh (или .\gradlew jmh для Linux, Mac, BSD)


С плагином есть пара интересных особенностей на Windows:


  • JMH использует fork java процесса. В случае Windows это сделать так просто нельзя, а потом новый процесс просто запускается с тем же classpath. И весь список jar файлов передается через командную строку, размер которой ограничен. В итоге, если GRADLE_USER_HOME (папка, внутри которой лежит в том числе кеш gradle) находится в глубине файловой структуры, список jar файлов для fork становится настолько большим, что Windows отказывается запускать процесс с таким громадным число аргументов командной строки. Следовательно, если JMH отказывается делать fork — просто переместите кеши Gradle в папку с коротким именем, т.е. запишите в environment variable GRADLE_USER_HOME что-то вроде c:\gradle
  • Иногда предыдущий процесс JMH делает lock на файле (возможно, это делает byte code rewrite). В итоге, повторная компиляция может не работать, так как файл с нашим benchmark открыт кем-то на запись. Чтобы исправить эту проблему необходимо просто остановить deamon процессы gradle (которые уже запущены, чтобы ускорить работу компилятора): .\gradlew.bat --stop
  • Для чистоты экспериментов, лучше отказаться от инкрементальной сборки для наших тестов. Отсюда, перед тестированием всегда вызываем .\gradlew.bat clean

Тестирование


В качестве примера я возьму вопрос (ранее заданный на kotlin discussions), который мучал меня ранее — зачем в конструкции use используется inline метод?


О конструкции use

В Java есть паттерн — try with resources, который позволяет автоматически вызывать метод close внутри блока, более того — безопасно обрабатывать исключения, не перекрывая уже летящее. Аналог из мира .Net — конструкция using для интерфейсов IDisposable.


Пример java кода:


try (BufferedReader reader = Files.newBufferedReader(file, charset)) { //именно этот try и является озвученной конструкцией
    /*читаем из reader'а*/
}

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


Files.newBufferedReader(file, charset)).use { reader ->
    /*читаем из reader'а*/
}

То есть, как видно:


  1. Use — это просто метод-расширение, а не отдельная конструкция языка
  2. Use является inline методом, то есть одни и те же конструкции встраиваются в каждый метод, что увеличивает размер байткода, а значит в теории JIT`у будет сложнее оптимизировать код и т.д. И вот эту теорию мы и будем проверять.

Итак, необходимо сделать два метода:


  1. Первый будет просто использовать use, который поставляется в библиотеке kotlin
  2. Второй будет использовать те же методы, однако без inline. В итоге на каждый вызов в куче будет создаваться объект с параметрами для лямбды.

Код с JMH аттрибутами, который будет запускать разные функции:


@BenchmarkMode(Mode.All) // тестируем во всех режимах
@Warmup(iterations = 10) // число итераций для прогрева нашей функции
@Measurement(iterations = 100, batchSize = 10) // число проверочных итераций, причем в каждой из них будет по четыре вызова функции
open class CompareInlineUseVsLambdaUse {

    @Benchmark
    fun inlineUse(blackhole: Blackhole) {
        NoopAutoCloseable(blackhole).use {
            blackhole.consume(1)
        }
    }

    @Benchmark
    fun lambdaUse(blackhole: Blackhole) {
        NoopAutoCloseable(blackhole).useNoInline {
            blackhole.consume(1)
        }
    }
}

Dead Code Elimination


Java Compiler & JIT довольно умные и имеют ряд оптимизаций, как в compile time, так и в runtime. Метод ниже, например, вполне может свернуться в одну строку (как для kotlin, так и для java):


fun sum() : Unit {
    val a = 1
    val b = 2

    a + b;
}

И в итоге мы будем тестировать метод:


fun sum() : Unit {
    3;
}

Однако результат ведь никак не используется, потому компиляторы (byte code + JIT) в итоге вообще выкинут метод, так как он в принципе не нужен.


Чтобы избежать этого, в JMH существует специальный класс "черная дыра" — Blackhole. В нем есть методы, которые с одной стороны не делают ничего, а с другой стороны — не дают JIT выкинуть ветку с результатом.


А для того, чтобы javac не пытался сложить-таки a и b в процессе компиляции, нам требуется определить объект state, в котором будут храниться наши значения. В итоге в самом тесте мы будем использовать уже подготовленный объект (то есть не тратим время на его создание и не даем компилятору применить оптимизации).


В итоге для грамотного тестирования нашей функции требуется её написать вот в таком виде:


fun sum(blackhole: Blackhole) : Unit {
    val a = state.a // компилятор не знает заранее значение a
    val b = state.b

    val result = a + b;

    blackhole.consume(result) // JIT не может выкинуть сложение, так как результат кому-то все-таки нужен
}

Здесь мы взяли a и b из некоторого state, что помешает компилятору сразу посчитать выражение. А результат мы отправили в черную дыру, что помешает JIT выкинуть последнюю часть функции.


Возвращаясь к моей функции:


  1. Объект для вызова метода close я создам в самом тесте, так как практически всегда при вызове метода close у нас до этого создавался объект.
  2. Внутри нашего метода придется вызывть функцию из blackhole, чтобы спровоцировать создание лямбды в куче (и не дать JIT выкинуть потенциально ненужный код).

Результат теста


Запустив ./gradle jmh, а потом подождав два часа, я получил следующие результаты работы на моем mac mini:


# Run complete. Total time: 01:51:54

Benchmark                                                  Mode       Cnt         Score       Error  Units
CompareInlineUseVsLambdaUse.inlineUse                     thrpt      1000  11689940,039 ± 21367,847  ops/s
CompareInlineUseVsLambdaUse.lambdaUse                     thrpt      1000  11561748,220 ± 44580,699  ops/s
CompareInlineUseVsLambdaUse.inlineUse                      avgt      1000        ≈ 10⁻⁷               s/op
CompareInlineUseVsLambdaUse.lambdaUse                      avgt      1000        ≈ 10⁻⁷               s/op
CompareInlineUseVsLambdaUse.inlineUse                    sample  21976631        ≈ 10⁻⁷               s/op
CompareInlineUseVsLambdaUse.inlineUse:inlineUse·p0.00    sample                  ≈ 10⁻⁷               s/op
CompareInlineUseVsLambdaUse.inlineUse:inlineUse·p0.50    sample                  ≈ 10⁻⁷               s/op
CompareInlineUseVsLambdaUse.inlineUse:inlineUse·p0.90    sample                  ≈ 10⁻⁷               s/op
CompareInlineUseVsLambdaUse.inlineUse:inlineUse·p0.95    sample                  ≈ 10⁻⁷               s/op
CompareInlineUseVsLambdaUse.inlineUse:inlineUse·p0.99    sample                  ≈ 10⁻⁷               s/op
CompareInlineUseVsLambdaUse.inlineUse:inlineUse·p0.999   sample                  ≈ 10⁻⁵               s/op
CompareInlineUseVsLambdaUse.inlineUse:inlineUse·p0.9999  sample                  ≈ 10⁻⁵               s/op
CompareInlineUseVsLambdaUse.inlineUse:inlineUse·p1.00    sample                   0,005               s/op
CompareInlineUseVsLambdaUse.lambdaUse                    sample  21772966        ≈ 10⁻⁷               s/op
CompareInlineUseVsLambdaUse.lambdaUse:lambdaUse·p0.00    sample                  ≈ 10⁻⁸               s/op
CompareInlineUseVsLambdaUse.lambdaUse:lambdaUse·p0.50    sample                  ≈ 10⁻⁷               s/op
CompareInlineUseVsLambdaUse.lambdaUse:lambdaUse·p0.90    sample                  ≈ 10⁻⁷               s/op
CompareInlineUseVsLambdaUse.lambdaUse:lambdaUse·p0.95    sample                  ≈ 10⁻⁷               s/op
CompareInlineUseVsLambdaUse.lambdaUse:lambdaUse·p0.99    sample                  ≈ 10⁻⁷               s/op
CompareInlineUseVsLambdaUse.lambdaUse:lambdaUse·p0.999   sample                  ≈ 10⁻⁵               s/op
CompareInlineUseVsLambdaUse.lambdaUse:lambdaUse·p0.9999  sample                  ≈ 10⁻⁵               s/op
CompareInlineUseVsLambdaUse.lambdaUse:lambdaUse·p1.00    sample                   0,010               s/op
CompareInlineUseVsLambdaUse.inlineUse                        ss      1000        ≈ 10⁻⁵               s/op
CompareInlineUseVsLambdaUse.lambdaUse                        ss      1000        ≈ 10⁻⁵               s/op

Benchmark result is saved to /Users/imanushin/git/use-performance-test/src/build/reports/jmh/results.txt

Или, если сократить таблицу:


Benchmark  Mode        Cnt         Score       Error  Units
inlineUse   thrpt      1000  11689940,039 ± 21367,847  ops/s
lambdaUse   thrpt      1000  11561748,220 ± 44580,699  ops/s
inlineUse    avgt      1000        ≈ 10⁻⁷               s/op
lambdaUse    avgt      1000        ≈ 10⁻⁷               s/op
inlineUse  sample  21976631        ≈ 10⁻⁷               s/op
lambdaUse  sample  21772966        ≈ 10⁻⁷               s/op
inlineUse      ss      1000        ≈ 10⁻⁵               s/op
lambdaUse      ss      1000        ≈ 10⁻⁵               s/op

В результате есть две самые важные метрики:


  1. Inline метод показал производительность 11,6 * 10^6 ± 0,02 * 10^6 операций в секунду.
  2. Lambda-based метод показал производительность 11,5 * 10^6 ± 0,04 * 10^6 операций в секунду.
  3. Inline метод в итоге работает быстрее и стабильнее по скорости. Возможно, увеличенная погрешность для lambdaUse связана с более активной работой с памятью.
  4. Я таки был неправ на том форуме — в стандартной библиотеке kotlin лучше оставить текущую реализацию метода.

Заключение


При разработке ПО есть два довольно частых способа сравнения производительности:


  1. Замер скорости работы цикла с N итерациями подопытной функции.
  2. Философские рассуждения вида "я уверен, что быстрее использовать сдвиг, чем умножение на 2", "сколько я программирую, всегда XML сериализация была самой быстрой" и тд.

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

  • +10
  • 7,7k
  • 8
Поделиться публикацией

Похожие публикации

Комментарии 8
    0
    С какой стати автор уверен что использование каких то библиотек, да ещё и компиляция заместа запуска в JVM даст «более корректный» результат чем просто запуск программы в JVM приближённый к реальной работе?
      0
      компиляция заместа запуска в JVM

      Нет, код запускается в той же JVM. Просто в тест добавится N раз вызов вашей функции, добавятся замеры, прогревы и так далее. Всё это можно закодить самостоятельно.


      запуск программы в JVM приближённый к реальной

      Запускать тест, что очевидно, надо в тех условиях, где он будет работать. Если в проде Linux x64 c большим объемом памяти — то именно там, а не на ARM'е.


      использование каких то библиотек

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

        0
        Нет, код запускается в той же JVM. Просто в тест добавится N раз вызов вашей функции, добавятся замеры, прогревы и так далее. Всё это можно закодить самостоятельно.

        понятно, спс, а есть то же самое или максимально приближённое чисто на яве?
    +1
    Все же для большого класспаса есть более продвинутый костыль — ClassPathManifest. Тем более предложенное решение с переименованием домашней директории Gradle для проекта с большим количеством внешних зависимостей так же не будет работать.
      0

      Да-да-да, Вы полностью правы. Gradle именно его и использует, то есть создает файл, туда записывает кучу путей к jar файлам. Это баг самой JMH. Я описал лишь способ подавить её.


      Более того: в самом плагине есть тоже баг, так как в classpath откуда-то попали еще зависимости с именами gradle и kotlin compiler. Последнее наводит меня на мысль, что сам classpath в принципе содержит лишнее.

        +1

        Мне кажетсяя этим "грешит" в том числе и gradle плагин 'application', он генерирует файлы запуска java приложения (*.sh и *.bat) которые в Windows не работают, из-за ошибки "Input line too long". Но этого можно избежать, добавив в build.gradle нехитрую регулярку (не сильно разбираюсь в groovy, но она работает))


        /* Workaround for Windows cmd "Input line too long" bug */
        startScripts {
          doLast {
            def windowsScriptFile = file getWindowsScript()
            windowsScriptFile.text = windowsScriptFile.text.replaceFirst(/set CLASSPATH=%APP_HOME%\\lib\\.+/,"set CLASSPATH=%APP_HOME%\\\\lib\\\\*;")
          }
        }

        т.е. просто заменив перечисление всех файлов в classpath на * wildcard in classpath. Кажется эту возможность добавили еще в java 6, но этот плагин по-прежнему её не использует. Возможно таким способом можно и gradle-jmh plugin "починить" ))

      0
      Как вам совершенно правильно ответил yole, основная причина вообще не в производительности, а чтобы можно было return написать.

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

      Самое читаемое