Тестируем производительность кода с помощью Jetpack Microbenchmark
В мобильной разработке периодически возникают ситуации, когда нужно оценить время выполнения кода. Помимо теоретических подходов (например, Big O), которые позволяют отсеять очевидно неудачные решения, существуют бенчмарки для тестирования кода и поиска более мелких отличий.
В этой статье расскажу, как устроена и работает библиотека Microbenchmark от Google, а также покажу примеры использования. С ней можно не только оценить производительность, но и решить спорные ситуации на код-ревью.
Когда нужно оценить время выполнения кода, первое, что приходит в голову, выглядит примерно так:
val startTime = System.currentTimeMillis()
//выполняем код который хотим оценить
val totalTime = System.currentTimeMillis() - startTime
Такой подход прост, но имеет несколько недостатков:
не учитывает «прогрев» исследуемого кода;
не учитывает состояние устройства, например, Thermal Throttling;
даёт только один результат, без представления о дисперсии времени выполнения;
может усложнить изоляцию тестируемого кода.
Именно по этим причинам оценка времени выполнения не такая тривиальная задача, как может показаться на первый взгляд. Существует решение в виде, например, Firebase Performance Monitoring, но оно больше подходит для мониторинга производительности в продакшене и не очень подходит для изолированных частей кода.
С решением этой задачи лучше справится библиотека от Google.
Что такое Microbenchmark
Microbenchmark — это библиотека из состава Jetpack, которая позволяет быстро оценивать время выполнения Kotlin и Java кода. Она до некоторой степени может исключать влияние прогрева, троттлинга и других факторов на конечный результат, а ещё — генерировать отчеты в консоль или JSON-файл. Также инструмент можно использовать с CI, что позволит замечать проблемы с производительностью на начальных этапах.
Подробную информацию о подключении и настройке можно найти в документации. Или в репозитории GitHub.
Наилучшие результаты библиотека даёт при профилировании кода, который используется неоднократно. Хорошими примерами будет скроллинг RecyclerView, преобразование данных и так далее.
Также желательно исключить влияние кеша, если он есть — сделать это можно с помощью генерации уникальных данных перед каждым прогоном. А ещё тесты на производительность требуют специфичных настроек (например, выключенного debuggable), поэтому правильным решением будет вынести их в отдельный модуль.
Как работает Microbenchmark
Посмотрим, как устроена библиотека.
Запуски всех benchmark происходят внутри IsolationActivity (за первый запуск отвечает класс AndroidBenchmarkRunner), здесь и происходит начальная настройка.
Она состоит из следующих шагов:
Наличие других Activity с тестом. В случае дублирования тест упадёт с исключением — «Only one IsolationActivity should exist».
Проверка поддержки Sustained Mode. Это такой режим, в котором устройство может поддерживать постоянный уровень производительности, что хорошо сказывается на консистентности результатов.
Запуск параллельного с тестом процесса BenchSpinThread с THREAD_PRIORITY_LOWEST. Это сделано, чтобы как минимум одно ядро было постоянно загруженным, работает только в комбинации с Sustained Mode.
Если в общих чертах: работа бенчмарка состоит в том, чтобы запустить код из теста некоторое число раз и измерить среднее время его выполнения. Но есть тонкости. Например, при таком подходе первые запуски будут занимать в несколько раз больше времени. Причина в том, что в тестируемом коде может быть зависимость, которая тратит много времени на инициализацию. В чём-то это похоже на двигатель автомобиля, которому необходимо некоторое время на прогрев.
Перед контрольными запусками нужно убедиться, что всё работает в штатном режиме и прогрев завершён. В коде библиотеки его окончанием считается состояние, когда очередной запуск теста даёт результат, укладываемый в границы некой погрешности.
Вся основная логика содержится в классе WarmupManager, именно здесь и происходит вся магия. В методе onNextIteration находится логика определения, является ли benchmark стабильным. В переменных fastMovingAvg и slowMovingAvg хранятся средние показатели по времени выполнения benchmark, которые сходятся к среднему значению с некоторой погрешностью (погрешность хранится внутри константы TRESHOLD).
fun onNextIteration(durationNs: Long): Boolean {
iteration++
totalDuration += durationNs
if (iteration == 1) {
fastMovingAvg = durationNs.toFloat()
slowMovingAvg = durationNs.toFloat()
return false
}
fastMovingAvg = FAST_RATIO * durationNs + (1 - FAST_RATIO) * fastMovingAvg
slowMovingAvg = SLOW_RATIO * durationNs + (1 - SLOW_RATIO) * slowMovingAvg
// If fast moving avg is close to slow, the benchmark is stabilizing
val ratio = fastMovingAvg / slowMovingAvg
if (ratio < 1 + THRESHOLD && ratio > 1 - THRESHOLD) {
similarIterationCount++
} else {
similarIterationCount = 0
}
if (iteration >= MIN_ITERATIONS && totalDuration >= MIN_DURATION_NS) {
if (similarIterationCount > MIN_SIMILAR_ITERATIONS ||
totalDuration >= MAX_DURATION_NS) {
// benchmark has stabilized, or we're out of time
return true
}
}
return false
}
Помимо прогрева кода внутри библиотеки реализовано обнаружение Thermal Throttling. Допускать влияние такого состояния на тесты не стоит, потому что из-за дросселирования тактов увеличивается среднее время выполнения.
Обнаружение перегрева работает намного проще, чем WarmupManager. В методе isDeviceThermalThrottled проверяется время выполнения небольшой тестовой функции внутри этого класса. А именно — замеряется время копирования небольшого ByteArray.
private fun measureWorkNs(): Long {
// Access a non-trivial amount of data to try and 'reset' any cache state.
// Have observed this to give more consistent performance when clocks are unlocked.
copySomeData()
val state = BenchmarkState()
state.performThrottleChecks = false
val input = FloatArray(16) { System.nanoTime().toFloat() }
val output = FloatArray(16)
while (state.keepRunningInline()) {
// Benchmark a simple thermal
Matrix.translateM(output, 0, input, 0, 1F, 2F, 3F)
}
return state.stats.min
}
/**
* Called to calculate throttling baseline, will be ignored after first call.
*/
fun computeThrottleBaseline() {
if (initNs == 0L) {
initNs = measureWorkNs()
}
}
/**
* Makes a guess as to whether the device is currently thermal throttled based on performance
* of single-threaded CPU work.
*/
fun isDeviceThermalThrottled(): Boolean {
if (initNs == 0L) {
// not initialized, so assume not throttled.
return false
}
val workNs = measureWorkNs()
return workNs > initNs * 1.10
}
Полученные выше данные используются при запуске основных тестов. Они помогают исключать прогоны для прогрева и те, на которые влияет троттлинг (если он есть). По умолчанию выполняется 50 значимых прогонов, при желании это число и другие константы легко меняются на необходимые. Но нужно быть осторожными — это может сильно повлиять на работу библиотеки.
@Before
fun init() {
val field = androidx.benchmark.BenchmarkState::class.java.getDeclaredField("REPEAT_COUNT")
field.isAccessible = true
field.set(benchmarkRule, GLOBAL_REPEAT_COUNT)
}
Немного практики
Попробуем поработать с библиотекой как обычные пользователи. Протестируем скорость чтения и записи JSON для GSON и Kotlin Serialization.
@RunWith(AndroidJUnit4::class)
class KotlinSerializationBenchmark {
private val context = ApplicationProvider.getApplicationContext()
private val simpleJsonString = Utils.readJsonAsStringFromDisk(context, R.raw.simple)
@get:Rule val benchmarkRule = BenchmarkRule()
@Before
fun init() {
val field = androidx.benchmark.BenchmarkState::class.java.getDeclaredField("REPEAT_COUNT")
field.isAccessible = true
field.set(benchmarkRule, Utils.GLOBAL_REPEAT_COUNT)
}
@Test
fun testRead() {
benchmarkRule.measureRepeated {
Json.decodeFromString>(simpleJsonString ?: "")
}
}
@Test
fun testWrite() {
val testObjects = Json.decodeFromString>(simpleJsonString ?: "")
benchmarkRule.measureRepeated {
Json.encodeToString(testObjects)
}
}
}
Для оценки результатов тестирования можно воспользоваться консолью в Android Studio или сформировать отчёт в JSON-файле. Причём детализация отчёта в консоли и файле очень сильно отличается: в первом случае получится узнать только среднее время выполнения, а во втором — полный отчёт со временем каждого прогона (полезно для построения графиков) и другой информацией.
Настройка отчётов находится в окне Edit Run Configuration > Instrumentation Extra Params. Параметр, который отвечает за сохранение отчётов, называется androidx.benchmark.output.enable. Дополнительно здесь можно настроить импорт значений из Gradle, что будет полезно при запуске на CI.
Настройки запуска тестов производительности с включенными отчетами
Теперь при выполнении тестов, отчёты будут сохраняться в директорию приложения, а имя файлов соответствовать имени класса. Пример структуры отчёта можно посмотреть здесь.
Заключение
На нашем проекте данный инструмент применялся для поиска лучшего решения среди парсеров JSON. В итоге победил Kotlin Serialization. При этом очень не хватало профилирования по потреблению CPU и памяти во время тестирования — их приходилось снимать отдельно.
Может показаться, что инструмент обладает малым функционалом, его возможности ограничены, а область применения весьма специфична. В целом, так и есть, но в некоторых случаях он может оказаться очень полезным. Вот несколько кейсов:
Оценка производительности новой библиотеки в проекте.
Решение спорных ситуаций на код-ревью, когда необходимо обосновать выбор в пользу того или иного решения.
Сбор статистики и оценка качества кода в течение долгого периода времени при интеграции с CI.
Ещё у Microbenchmark есть старший брат — Macrobenchmark, который предназначен для оценки UI-операций, например, запуска приложения, скроллинга и анимации. Но это уже тема для отдельной статьи.