Примеры из практики

Как R8 ускорил работу Kotlin Coroutines на Android в 2 раза

7 минут чтения
Посмотреть профиль Джонатана Старупа Посмотреть профиль Андрея Шикова
Jonathan Starup и Andrei Shikov

Начиная с AGP 9.2.0, R8 оптимизирует большинство вызовов Atomic*FieldUpdater, преобразуя их в небезопасные варианты, которые обеспечивают в 2-4 раза более высокую производительность при выполнении распространенных операций . Это особенно сильно влияет на библиотеку kotlinx.atomicfu, которая реализует атомарные операции для kotlinx.coroutines , ускоряя запуск и отмену сопрограмм до 2 раз. Чтобы воспользоваться преимуществами, обновите AGP до версии 9.2.0 или выше.

Поскольку большинство приложений для Android используют Kotlin в качестве основного языка программирования, библиотека kotlinx.coroutines стала стандартом де-факто для асинхронного программирования. Библиотека предлагает хорошо продуманный и структурированный способ управления параллельными потоками, присущий Kotlin. Jetpack Compose не стал исключением, используя сопрограммы для управления событиями указателей, анимацией и другими взаимодействиями. На момент написания статьи большинство API для параллельного выполнения в Compose suspend функции и запускают и/или отменяют сопрограммы для обработки обновлений.

Когда команда Compose начала исследовать производительность, выяснилось, что сопрограммы являются узким местом для многих операций, происходящих вне Compose. Например, 80% времени, затрачиваемого на создание и обновление Modifier.clickable , уходило на запуск и отмену внутренних сопрограмм, обрабатывающих обновления InteractionSource . На основе этих наблюдений большая часть работы по повышению производительности на начальном этапе была сосредоточена на удалении сопрограмм из пути по умолчанию и отсрочке их инициализации до момента необходимости.

Стоимость сопрограммы

Самый простой способ проанализировать внутреннее поведение функции в Android — это получить трассировку методов Android Runtime (ART). Трассировка методов ART — это инструмент, который записывает поток выполнения приложения, точно показывая, какие методы вызываются, в каком порядке и сколько времени тратится на каждый из них, что позволяет разработчикам выявлять узкие места в производительности. Для пустого вызова LaunchedEffect { } это будет выглядеть примерно так:

pic01_enhanced.png
Трассировка метода LaunchedEffect визуализируется в пользовательском интерфейсе Perfetto.

Приведенный выше трассировочный вывод можно разделить на три части:

  • Инициализация новой сопрограммы
  • Запуск сопрограммы
  • Завершение сопрограммы (поскольку она немедленно завершается)

Отмена LaunchedEffect аналогична обычному завершению, за исключением того, что при этом также создается исключение CancellationException .

Из приведенного выше профиля сразу же настораживает частое обращение к java.util.concurrent.AtomicReferenceFieldUpdater (фиолетовые или зеленые прямоугольники с метками j…). Хотя каждый вызов выполняется относительно быстро, частота вызывает беспокойство; любые существенные накладные расходы, распределенные между несколькими вызовами, могут привести к заметному снижению производительности. При более детальном рассмотрении вызова становится ясно, что большая часть времени тратится на… проверки рефлексии?

pic02-enhanced.png
Подробный анализ трассировки метода AtomicReferenceFieldUpdater.get во время инициализации LaunchedEffect.

Корутины реализуют древовидную структуру без блокировок для отношений «родитель-потомок», что делает возможной структурированную параллельность. Оказывается, библиотека kotlinx.atomicfu реализует атомарные операции без блокировок, используя хорошо известный примитив JVM — AtomicReferenceFieldUpdater . Обновление использует ссылку на класс и имя поля для выполнения атомарных операций во время выполнения, и ему приходится выполнять несколько проверок безопасности с помощью рефлексии, чтобы убедиться, что поле существует и доступно. Каждая операция в корутинах (запуск, приостановка, отмена, завершение) вызывает как минимум одну атомарную операцию, поэтому, если она медленная, корутины будут работать плохо.

Исследование AtomicReferenceFieldUpdater

Но давайте не будем забегать вперед. AtomicReferenceFieldUpdater на самом деле хорошо оптимизирован для JVM уже более 10 лет , и трассировка методов может зафиксировать накладные расходы, которые полностью устраняются оптимизацией на уровне виртуальной машины: компиляцией «на лету» (JIT) или компиляцией «вперед» (AOT). Чтобы проверить производительность, давайте напишем несколько бенчмарков для измерения разницы между атомарными ссылками из kotlinx.atomicfu и java.util.concurrent.atomic .

@RunWith(AndroidJUnit4::class)
class AtomicReferenceBenchmark {
    @get:Rule
    val benchmarkRule = BenchmarkRule()
    
    private val atomicReference = java.util.concurrent.atomic.AtomicReference(false)
    private val atomicRef = kotlinx.atomicfu.atomic<Boolean>(false)
    
    @Test
    fun atomicReference_compareAndSet() {
        benchmarkRule.measureRepeated { 
            atomicReference.compareAndSet(true, false)
            atomicReference.compareAndSet(false, true)
        }
    }

     @Test
    fun atomicRef_compareAndSet() {
        benchmarkRule.measureRepeated {
            atomicRef.compareAndSet(true, false)
            atomicRef.compareAndSet(false, true)
        }
    }

    /* measuring other methods from the method traces above */
}

Запуск этого бенчмарка на Pixel 5 (при условии, что AtomicReferenceFieldUpdater#compareAndSet компилируется JIT-компилятором во время предварительной обработки) дает следующие результаты на Pixel 5 (API 33):

 50.7 ns  atomicReference_compareAndSet
135   ns  atomicRef_compareAndSet

Измерения подтверждают разницу: версия kotlinx.atomicfu явно примерно в 2,7 раза медленнее. Это подтверждает, что ART не выполняет никаких скрытых оптимизаций, а рефлексивные проверки доступа добавляют реальные накладные расходы во время выполнения.

Оглядываясь на исходный трассировочный код метода, можно заметить, что единственная значимая работа, выполняемая AtomicReferenceFieldUpdater — это внутренний вызов Unsafe.getObjectVolatile , который фактически выполняет базовую атомарную операцию. В большинстве случаев инициализатор обновления является статическим и может быть доказан как всегда корректный на основе структуры окружающего класса. Таким образом, можно статически проанализировать большинство случаев использования AtomicReferenceFieldUpdater и заменить их внутренним вариантом Unsafe во время компиляции. К тому же, в инструментальной цепочке сборки Android есть собственный оптимизирующий компилятор, который может сделать именно это.

Оптимизация с помощью R8

Классы Atomic*FieldUpdater поддерживают тонкое, динамическое и основанное на рефлексии использование, но часто применяются в статически очевидных шаблонах. Это объясняет как низкую базовую производительность, так и необходимость оптимизации. R8 — это компилятор, оптимизирующий всю программу, и он хорошо подходит для выявления более простых шаблонов, чтобы избежать накладных расходов на проверки безопасности с помощью рефлексии. R8 получает байт-код JVM после компилятора Java или Kotlin, но для облегчения читаемости эти примеры представлены в синтаксисе Java. Именно поэтому у класса AtomicReferenceFieldUpdater нет аргументов типа.

class Example {
    volatile String data = "";
    static final AtomicReferenceFieldUpdater updater =
        AtomicReferenceFieldUpdater.newUpdater(Example.class, String.class, "data");

    void example() {
        // ...
        updater.compareAndSet(this, "", "new");
        // ...
    }
}

В базовом примере создается статический конечный обновлятор, который обращается к полю с переменным значением типа volatile, используя простые константные аргументы для владельца, типа и имени поля. Используемая рефлексия полностью прозрачна. Ясно видно, что этот обновлятор ссылается на допустимое поле и что место создания обновлятора имеет допустимый доступ к этому полю.

По сути, Atomic*FieldUpdater — это обертка над смещением поля и вызовами Unsafe . Наилучший вариант оптимизации — заменить поле обновления полем смещения, а вызовы обновления — вызовами Unsafe .

Оптимизация Atomic*FieldUpdater

Оптимизация осуществляется в три этапа: инструментарий, замена и очистка.

Приборы

Первым шагом является добавление полей смещения (offset) рядом с полем обновления (updater), чтобы обеспечить прямой доступ через вызов Unsafe .

static final long updater$offset =
    SyntheticUnsafe.UNSAFE.objectFieldOffset(Example.class.getDeclaredField("data"))

Доступ к полю осуществляется через рефлексию, а Unsafe используется для извлечения смещения поля в классе. Этот код представляет собой внутреннее устройство Atomic*FieldUpdater если не учитывать проверку с помощью рефлексии. Вместо этого тип держателя обновляющего объекта и тип поля volatile отслеживаются статически в компиляторе.

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

Замена

На этом этапе компиляции, после подходящей точки соединения для параллельного выполнения, у нас есть список инструментированных полей обновления. Это означает, что мы можем оптимизировать каждый вызов индивидуально на основе нескольких условий. Рассмотрим пример вызова:

updater.compareAndSet(holder, expectedValue, newValue);

Для работы Atomic*FieldUpdater необходимы следующие условия:

  • Получается ли updater из инструментированного поля? То есть, может ли статический анализ отследить значение объекта до поля, прочитанного из инструментированного объекта обновления?
  • Является ли holder тем же классом или подклассом первоначально определенного типа владельца?
  • Является ли newValue тем же классом или подклассом исходного типа поля?

Если все условия выполнены, то вызов заменяется вызовом в Unsafe без каких-либо проверок на отражение.

SyntheticUnsafe.UNSAFE.compareAndSwapObject(holder, Example.updater$offset, expectedValue, newValue)

Новый вызов быстрее и проще, но отличается от исходного вызова обработкой нулевых значений в updater и holder . Если статически не исключено, проверка на нулевые значения выполняется для обоих методов.

Уборка

На данном этапе класс хранения содержит исходное поле обновления и новое поле смещения, а также места вызовов, которые могут использовать любое из этих двух полей. Если ни одно из мест вызовов не было оптимизировано, то поле смещения следует удалить, а если все места вызовов были оптимизированы, то поле обновления следует удалить. В обоих случаях вызов инициализации также следует удалить. Удаление неиспользуемых полей и мертвого кода уже выполняется в компиляторе, но удаление кода инициализации здесь требует еще нескольких ухищрений.

Вызов методов newUpdater и getDeclaredField может иметь побочные эффекты, поскольку они могут вызывать исключения (и их реализация также неизвестна, поскольку зависит от версии API). Это означает, что с помощью общей оптимизации их нельзя безопасно удалить. Поэтому для этой очистки потребовалось явное рассмотрение инструментированных полей, поскольку статически известно, что они не подвержены исключениям.

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

Результаты

После этих оптимизаций kotlinx.atomicfu и большинство случаев явного использования AtomicInt/Long/ReferenceFieldUpdater теперь соответствуют производительности AtomicReference с применением R8. Фактически, в некоторых тестах он даже быстрее; kotlinx.atomicfu имеет плагин компилятора , который может встраивать atomic экземпляры в поля, уменьшая количество выделений памяти, необходимых для создания атомарно обновляемого поля.

Главным бенефициаром этой работы стал Jetpack Compose. В среде выполнения Compose есть ряд микротестов, которые очень внимательно отслеживают производительность сопрограмм, чтобы выявлять регрессии производительности на ранних стадиях. После обновления тестов до новой версии R8 мы заметили двукратное улучшение при запуске и отмене сопрограмм в LaunchedEffect !

pic03_enhanced.png
График производительности, иллюстрирующий время, затраченное на запуск и отмену сопрограмм в LaunchedEffect (чем меньше значение, тем лучше). Изменение на графике соответствует обновлению до R8, демонстрирующему двукратное улучшение.

Помимо этого, команда ART внедряет эти оптимизации на уровне виртуальной машины. Если ваше приложение ориентировано на API 36 и работает на последней версии Android, возможно, ваше устройство уже оптимизирует сопрограммы аналогичным образом. Приведенные выше тесты производительности сопрограмм показали улучшение примерно на 15% после обновлений JIT в последних версиях ART.

Ваше приложение получит эту оптимизацию по умолчанию при обновлении до AGP 9.2.0 или при непосредственном использовании R8 9.2.0. Для получения дополнительной информации см. D8 dexer и R8 shrinker .

Автор:
Продолжить чтение