Начиная с версий Gradle 4.7 и Kotlin 1.3.30 появилась возможность получить ускорение инкрементальной сборки проектов за счет корректной работы инкрементальной обработки аннотаций. В статье разбираемся, как в теории работает модель инкрементальной компиляции в Gradle, что нужно сделать, чтобы раскрыть весь ее потенциал (не лишаясь при этом кодогенерации), и какой прирост к скорости инкрементальных сборок может дать активация инкрементальной обработки аннотаций на практике.
Как работает инкрементальная компиляция
Инкрементальная сборка в Gradle реализуется на двух уровнях. Первый уровень — это отмена запуска перекомпиляции модулей с помощью compile avoidance. Второй — непосредственно incremental compilation, запуск компилятора в рамках одного модуля только на тех файлах, которые изменились, либо имеют прямую зависимость от измененных файлов.
Рассмотрим compile avoidance на примере (взятом из статьи от Gradle) проекта из трех модулей: app, core и utils.
Основной класс модуля app (зависит от core):
public class Main {
public static void main(String... args) {
WordCount wc = new WordCount();
wc.collect(new File(args[0]);
System.out.println("Word count: " + wc.wordCount());
}
}
В модуле core (зависит от utils):
public class WordCount {
// ...
void collect(File source) {
IOUtils.eachLine(source, WordCount::collectLine);
}
}
В модуле utils:
public class IOUtils {
void eachLine(File file, Callable<String> action) {
try {
try (BufferedReader reader = new BufferedReader(new FileReader(file))) {
// ...
}
} catch (IOException e) {
// ...
}
}
}
Порядок первой компиляции модулей выглядит следующим образом (в соответствии с порядком следования зависимостей):
1) utils
2) core
3) app
Теперь рассмотрим, что произойдет при изменении внутренней реализации класса IOUtils:
public class IOUtils { // IOUtils lives in project `utils`
void eachLine(File file, Callable<String> action) {
try {
try (BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream(file), "utf-8") )) {
// ...
}
} catch (IOException e) {
// ...
}
}
}
Данное изменение не затрагивает ABI модуля. ABI (Application Binary Interface) — это бинарное представление публичного интерфейса собранного модуля. В случае, когда изменение относится только к внутренней реализации модуля и никак не отражается на его публичном интерфейсе, Gradle будет использовать compile avoidance и запустит перекомпиляцию только модуля utils. Если же будет затронут ABI модуля utils (например, появится дополнительный публичный метод или изменится сигнатура существующего), то дополнительно запустится перекомпиляция модуя core, но при этом зависящий от core модуль app не будет перекомпилирован транзитивно, если зависимость в нем подключена через implementation.
Иллюстрация compile avoidance на уровне модулей проекта
Второй уровень инкрементальности — это инкрементальность на уровне запуска компилятора для изменившихся файлов непосредственно внутри отдельно взятых модулей.
Например, добавим новый класс в модуль core:
public class NGrams { // NGrams lives in project `core`
// ...
void collect(String source, int ngramLength) {
collectInternal(StringUtils.sanitize(source), ngramLength);
}
// ...
}
И в utils:
public class StringUtils {
static String sanitize(String dirtyString) { ... }
}
В данном случае в обоих модулях необходимо перекомпилировать лишь два новых файла (не затрагивая уже существовавшие и не изменившиеся WordCount и IOUtils), так как между новыми и старыми классами нет зависимостей.
Таким образом, инкрементальный компилятор анализирует зависимости между классами и перекомпилирует только:
- классы, содержащие изменения
классы, которые непосредственно зависят от изменившихся классов
Инкрементальный annotation processing
Генерация кода с помощью APT и KAPT сокращает время на написание и отладку boilerplate-кода, однако обработка аннотаций может значительно увеличить время сборки. Что еще хуже, долгое время обработка аннотаций принципиально ломала возможности инкрементальной компиляции в Gradle.
Каждый процессор аннотаций в проекте сообщает компилятору информацию о списке обрабатываемых им аннотаций. Но с точки зрения сборки обработка аннотаций это black box: Gradle не знает, что будет делать процессор, в частности, какие файлы он будет генерировать и где. Вплоть до версии Gradle 4.7 инкрементальная компиляция автоматически отключалась на тех source set-ах, где использовались процессоры аннотаций.
С релизом Gradle 4.7 инкрементальная компиляция стала поддерживать annotation processing, но только для APT. В KAPT поддержка инкрементальной обработки аннотаций появилась с Kotlin 1.3.30. Для его работы необходима также поддержка со стороны библиотек, предоставляющих процессоры аннотаций. У разработчиков процессоров аннотаций появилась возможность явно задать категорию процессора, тем самым сообщая Gradle информацию, необходимую для работы инкрементальной компиляции.
Категории процессоров аннотаций
Gradle поддерживает две категории процессоров:
Isolating — такие процессоры должны принимать все решения для кодогенерации на основе лишь той информации из AST, которая связана с элементом конкретной аннотации. Это наиболее быстрая категория процессоров аннотаций, так как Gradle может не перезапускать процессор и использовать уже сгенерированные им ранее файлы, если в исходном файле не было изменений.
Aggregating — используется для процессоров, которые принимают решения на основе нескольких входов (например, анализ аннотаций сразу в нескольких файлах или на основе изучения AST, который транзитивно достижим от аннотированного элемента). Gradle будет каждый раз запускать процессор для файлов, в которых используются аннотации aggregating-процессора, но не будет перекомпилировать сгенерированные им файлы при отсутствии изменений в них.
Для многих популярных библиотек на основе кодогенерации поддержка инкрементальной компиляции уже реализована в последних версиях. Посмотреть список библиотек, которые ее поддерживают, можно здесь.
Наш опыт внедрения incremental annotation processing
Сейчас для проектов, которые стартуют с нуля и используют последние версии библиотек и gradle-плагинов, инкрементальная сборка, вероятнее всего, будет активна по-умолчанию. Но наибольшую долю прироста производительности сборки инкрементальность обработки аннотаций способна дать на больших и уже давно живущих проектах. В этом случае может потребоваться массивный апдейт версий. Стоит ли оно того на практике? Давайте-ка посмотрим!
Итак, для работы инкрементальной обработки аннотаций нам потребуется:
- Gradle 4.7+
- Kotlin 1.3.30+
- Все процессоры аннотаций в нашем проекте должны иметь ее поддержку. Это очень важно, потому что если в отдельно взятом модуле хотя бы один процессор не будет поддерживать инкрементальность, то Gradle отключит ее для всего модуля. Все файлы в модуле каждый раз будут компилироваться заново! Одним из альтернативных вариантов получения поддержки инкрементальной компиляции без апгрейда версий является вынос всего кода, использующего процессоры аннотаций, в отдельный модуль. В модулях, не имеющих процессоров аннотаций, инкрементальная компиляция будет работать нормально
Для того, чтобы обнаружить процессоры, не удовлетворяющие последнее условие, можно запустить сборку с флагом -Pkapt.verbose=true. Если Gradle был вынужден отключить incremental annotation processing для отдельно взятого модуля, то в логе сборки мы увидим сообщение о том, из-за каких процессоров и в каких модулях это происходит (см. название task):
> Task :common:kaptDebugKotlin
w: [kapt] Incremental annotation processing requested, but support is disabled because the following processors are not incremental: toothpick.compiler.factory.FactoryProcessor (NON_INCREMENTAL), toothpick.compiler.memberinjector.MemberInjectorProcessor (NON_INCREMENTAL).
На нашем проекте библиотек с неинкрементальными процессорами аннотаций оказалось 3:
- Toothpick
- Room
- PermissionsDispatcher
К счастью, эти библиотеки активно поддерживаются, и их последние версии уже имеют поддержку инкрементальности. Причем все процессоры аннотаций в последних версиях этих библиотеках имеют оптимальную категорию — isolating. В процессе поднятия версий пришлось столкнуться с рефакторингом из-за изменений в API библиотеки Toothpick, который коснулся практически каждого нашего модуля. Но в данном случае нам повезло, и рефакторинг получилось провести полностью автоматически с помощью автозамены названий используемых публичных методов библиотеки.
Обратите внимание, что в случае использования библиотеки Room вам будет необходимо явно передать флаг room.incremental: true процессору аннотаций. Пример. В дальнейшем разработчики Room планируют включить этот флаг по умолчанию.
Для версий Kotlin 1.3.30-1.3.50 необходимо включить поддержку инкрементальной обработки аннотаций явно через kapt.incremental.apt=true в файле gradle.properties проекта. Начиная с версии 1.3.50 эта опция имеет значение true по умолчанию.
Профайлинг инкрементальных сборок
После того как версии всех необходимых зависимостей были подняты, настало время протестировать скорость инкрементальных сборок. Для этого мы использовали следующий набор инструментов и приемов:
- Gradle build scan
- gradle-profiler
- Для прогона сценариев с включенной и отключенной инкрементальной обработкой аннотаций использовалось gradle-свойство kapt.incremental.apt=[true|false]
- Для устойчивых и информативных результатов сборки поднимались на отдельном CI-окружении. Инкрементальность сборок воспроизводилась при помощи gradle-profiler
gradle-profiler позволяет декларативно подготавливать сценарии для бенчмарков инкрементальной сборки. Было составлено 4 сценария, исходя из следующих условий:
- Изменение файла затрагивает/не затрагивает его ABI
- Поддержка инкрементальной обработки аннотаций включена/отключена
Прогон каждого из сценариев представляет собой последовательность из:
- Перезапуска gradle-демона
- Запуска warm-up сборки
- Запуска 10 инкрементальных сборок, перед каждой из которых происходит изменение файла путем добавления нового метода (private для non-ABI изменений и public для ABI изменений)
Все сборки проводились с Gradle 5.4.1. Файл, участвующий в изменениях, относится к одному из core-модулей проекта (common), от которого имеют прямую зависимость 40 модулей (в т.ч. core- и feature-). В данном файле используется аннотация для isolating процессора.
Также стоит отметить, что прогон бенчмарков проводился на двух gradle-задачах: сompileDebugSources и assembleDebug. Первая запускает только компиляцию файлов с исходным кодом, не производя при этом никакой работы с ресурсами и бандлингом приложения в .apk-файл. Исходя из того, что инкрементальная компиляция затрагивает только .kt и .java файлы, задача compileDedugSource была выбрана для более изолированного и быстрого бенчмаркинга. В реальных условиях разработки при перезапусках приложения Android Studio использует задачу assembleDebug, которая включает в себя полную генерацию debug-версии приложения.
Результаты бенчмарков
На всех приведенных далее графиках, сгенерированных gradle-profiler, по вертикальной оси отложено время инкрементальной сборки в миллисекундах, а по горизонтальной — номер запуска сборки.
:compileDebugSource до обновления процессоров аннотаций
Среднее время прогона каждого из сценариев составляло 38 секунд перед обновлением процессоров аннотаций до версий с поддержкой инкрементальности. В данном случае Gradle отключает поддержку инкрементальной компиляции, поэтому существенной разницы между сценариями нет.
:compileDebugSource после обновления процессоров аннотаций
Scenario | Incremental ABI change | Non-incremental ABI change | Incremental non-ABI change | Non-incremental non-ABI change |
---|---|---|---|---|
mean | 23978 | 35370 | 23514 | 34602 |
median | 23879 | 35019 | 23424 | 34749 |
min | 22618 | 33969 | 22343 | 33292 |
max | 26820 | 38097 | 25651 | 35843 |
stddev | 1193.29 | 1240.81 | 888.24 | 815.91 |
Медианное уменьшение времени сборки за счет инкрементальности составило 31% для ABI-изменений и 32.5% для non-ABI-изменений. В абсолютном значении около 10 секунд.
:assembleDebug после обновления процессоров аннотаций
Scenario | Incremental ABI change | Non-incremental ABI change | Incremental non-ABI change | Non-incremental non-ABI change |
---|---|---|---|---|
mean | 39902 | 49850 | 39005 | 52123 |
median | 38974 | 49691 | 38713 | 50336 |
min | 38563 | 48782 | 38233 | 48944 |
max | 48255 | 52364 | 41732 | 65941 |
stddev | 2953.28 | 1011.20 | 1015.37 | 5039.11 |
Для сборки полной debug-версии приложения на нашем проекте медианное уменьшение времени сборки за счет инкрементальности составило 21.5% для ABI-изменений и 23% для non-ABI-изменений. В абсолютном значении примерно те же 10 секунд, так как инкрементальность компиляции исходного кода не влияет на скорость сборки ресурсов.
Анатомия сборки в Gradle Build Scan
Для более глубоко понимания, за счет чего был достигнут прирост при инкрементальной компиляции, сравним сканы инкрементальной и неинкрементальной сборок.
В случае с отключенной инкрементальностью KAPT основную часть времени сборки составляет компиляция app-модуля, которая не может быть распараллелена с другими задачами. Таймлайн для неинкрементального KAPT выглядит следующим образом:
Выполнение задачи :kaptDebugKotlin нашего app-модуля занимает около 8 секунд в данном случае.
Таймлайн для случая с включенной инкрементальностью KAPT:
Теперь app-модуль перекомпилировался менее чем за секунду. Стоит обратить внимание на визуальную несоизмеримость масштабов двух сканов на пикчах выше. Задачи, которые кажутся короче на первом изображении, необязательно дольше выполняются на втором, где они кажутся длиннее. Но хорошо заметно насколько уменьшилась доля перекомпиляции app-модуля при включении инкрементального KAPT. В нашем случае мы выигрываем около 8 секунд на этом модуле и дополнительно около 2 секунд на меньших по размерам модулях, которые компилируются параллельно.
При этом суммарная длительность выполнения всех *kapt-задач для отключенной инкрементальности обработки аннотаций составляет 1 минуту и 36 секунд против 55 секунд при ее включении. То есть без учета параллельности сборки модулей выигрыш более существенен.
Также стоит отметить, что приведенные выше результаты бенчмарков были подготовлены на CI-окружении с возможностью запуска 24 параллельных потоков для сборки. На 8-поточном окружении выигрыш от включения инкрементальной обработки аннотаций составляет порядка 20-30 секунд на нашем проекте.
Incremental vs (?) parallel
Другим способом существенного ускорения сборки (как инкрементальной, так и чистой) является параллельное выполнение gradle-задач за счет разбиения проекта на большое количество слабо связанных модулей. Так или иначе, модуляризация представляет куда больший потенциал для ускорения сборок, чем использование инкрементального KAPT. Но чем монолитнее проект, и чем больше в нем используется кодогенерация, тем большую долю прироста будет давать инкрементальная обработка аннотаций. Получить эффект от полной инкрементальности сборок проще, чем разбивать приложение на модули. Тем не менее, оба подхода не противоречат и прекрасно дополняют друг друга.
Итог
- Включение инкрементальной обработки аннотаций на нашем проекте позволило достичь 20%-го прироста скорости локальных пересборок
- Для включения инкрементальной обработки аннотаций будет полезно изучить полный лог текущих сборок и поискать warning-сообщения с текстом "Incremental annotation processing requested, but support is disabled because the following processors are not incremental...". Необходимо обновить версии библиотек до версий с поддержкой инкрементальной обработки аннотаций и иметь версии Gradle 4.7+, Kotlin 1.3.30+
Материалы и что почитать по теме
- О поддержке Inremental annotation processing на уровне Java-плагина Gradle
- Статья о gradle-profiler
- Подробнее про возможности KAPT
- Доклад на Google I/O 2019 с актуальными трюками по ускорению сборок
- Еще один доклад об оптимизации Gradle на Google I/O 2017, включает материал по инкрементальной сборке и compile avoidance