Como o R8 tornou as corrotinas do Kotlin no Android duas vezes mais rápidas
Leitura de 7 minutos
A partir do AGP 9.2.0, o R8 otimiza a maioria das chamadas Atomic*FieldUpdater em variantes não seguras que têm uma performance de 2 a 4 vezes melhor em operações comuns. Isso tem um impacto particularmente grande na biblioteca kotlinx.atomicfu, que implementa operações atômicas para kotlinx.coroutines, tornando o lançamento e o cancelamento de corrotinas até duas vezes mais rápidos. Para aproveitar os benefícios, atualize o AGP para a versão 9.2.0 ou mais recente.
Com a maioria dos apps Android adotando o Kotlin como a principal linguagem de escolha, kotlinx.coroutines se tornou um padrão de fato para programação assíncrona. A biblioteca oferece uma maneira bem projetada e estruturada de gerenciar fluxos simultâneos nativos do Kotlin. O Jetpack Compose não foi exceção, adotando corrotinas para gerenciar eventos de ponteiro, animações e outras interações. No momento da redação, a maioria das APIs simultâneas no Compose chama funções suspend em segundo plano e inicia e/ou cancela corrotinas para processar atualizações.
À medida que a equipe do Compose começou a investigar a performance, as corrotinas foram descobertas como um gargalo para muitas operações que acontecem fora da composição. Por exemplo, 80% do tempo gasto na criação e atualização de Modifier.clickable foi consumido pelo lançamento e cancelamento de corrotinas internas que processavam atualizações de InteractionSource. Com base nessas observações, grande parte do trabalho inicial de performance foi focada na remoção de corrotinas do caminho padrão e no atraso da inicialização até que fosse necessário.
O custo de uma corrotina
A maneira mais fácil de analisar o comportamento interno de uma função no Android é capturar um rastreamento de método do Android Runtime (ART). Um rastreamento de método do ART é uma ferramenta que registra o fluxo de execução de um app, mostrando exatamente quais métodos são chamados, a ordem deles e quanto tempo é gasto em cada um, permitindo que os desenvolvedores identifiquem gargalos de performance. Para uma chamada LaunchedEffect { } vazia, ela seria parecida com esta:
O rastreamento de método acima pode ser separado em três partes:
- Inicialização de uma nova corrotina
- Corrotina inicial
- Corrotina concluída (porque ela sai imediatamente)
O cancelamento de LaunchedEffect é semelhante à conclusão normal, exceto que também cria uma CancellationException.
No perfil acima, uma coisa que é imediatamente suspeita são as chamadas frequentes para java.util.concurrent.AtomicReferenceFieldUpdater (caixas roxas ou verdes com rótulos j…). Embora cada chamada seja relativamente rápida, a frequência é preocupante. Qualquer sobrecarga não insignificante que seja distribuída por várias invocações pode resultar em uma regressão perceptível. Ao ampliar uma chamada, é possível perceber que a maior parte do tempo é gasto em… verificações de reflexão?
As corrotinas implementam uma estrutura de árvore sem bloqueio para relações pai-filho que possibilita a simultaneidade estruturada. A biblioteca kotlinx.atomicfu implementa operações atômicas sem bloqueio usando um primitivo JVM conhecido, AtomicReferenceFieldUpdater. O atualizador usa uma referência de classe e um nome de campo para realizar operações atômicas no ambiente de execução, e precisa executar várias verificações de segurança reflexivas para garantir que o campo exista e esteja acessível. Cada operação em corrotinas (início, suspensão, cancelamento, conclusão) chama pelo menos uma operação atômica. Portanto, se for lenta, as corrotinas não terão uma boa performance.
Como investigar o AtomicReferenceFieldUpdater
Mas vamos com calma. AtomicReferenceFieldUpdater é bem otimizado na JVM há mais de 10 anos, e os traces de método podem capturar a sobrecarga que é completamente removida por uma otimização de nível de VM: compilações just-in-time (JIT) ou ahead-of-time (AOT). Para verificar a performance, vamos escrever alguns comparativos para medir a diferença entre referências atômicas de kotlinx.atomicfu e 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 */ }
A execução desse comparativo em um Pixel 5 (garantindo que AtomicReferenceFieldUpdater#compareAndSet seja compilado JIT durante o aquecimento) gera os seguintes resultados no Pixel 5 (API 33):
50.7 ns atomicReference_compareAndSet 135 ns atomicRef_compareAndSet
As medições confirmam a lacuna, com a versão kotlinx.atomicfu sendo aproximadamente 2,7 vezes mais lenta. Isso confirma que o ART não realiza nenhuma otimização oculta e que as verificações de acesso reflexivo adicionam sobrecarga real durante o ambiente de execução.
Analisando o rastreamento de método original, o único trabalho significativo realizado pelo AtomicReferenceFieldUpdater é a chamada interna para Unsafe.getObjectVolatile, que executa a operação atômica subjacente. Na maioria dos casos, o inicializador do atualizador é estático e pode ser comprovado como sempre correto com base na estrutura da classe ao redor. Assim, é possível analisar estaticamente a maioria dos usos de AtomicReferenceFieldUpdater e substituí-los por uma variante Unsafe interna durante a compilação. A cadeia de ferramentas de build do Android também tem o próprio compilador de otimização que pode fazer exatamente isso.
Otimização com o R8
As Atomic*FieldUpdater classes oferecem suporte a uso sutil, dinâmico e baseado em reflexão, mas geralmente são usadas em padrões estaticamente óbvios. Isso explica a performance de linha de base lenta e a necessidade de otimização. O R8 é um compilador de otimização de programa completo e é adequado para analisar os padrões mais simples para reduzir a sobrecarga das verificações de segurança reflexivas. O R8 recebe bytecode JVM após o compilador Java ou Kotlin, mas para facilitar a leitura, esses exemplos são apresentados na sintaxe Java. É por isso que não há argumentos de tipo para AtomicReferenceFieldUpdater.
class Example { volatile String data = ""; static final AtomicReferenceFieldUpdater updater = AtomicReferenceFieldUpdater.newUpdater(Example.class, String.class, "data"); void example() { // ... updater.compareAndSet(this, "", "new"); // ... } }
O exemplo de base cria um atualizador final estático que acessa um campo volátil com argumentos constantes simples para o detentor, o tipo e o nome do campo. A reflexão usada é totalmente transparente. É claro que esse atualizador faz referência a um campo válido e que o site da criação do atualizador tem acesso válido ao campo.
Em essência, Atomic*FieldUpdater é um wrapper em torno de um deslocamento de campo e chamadas para Unsafe. O melhor cenário para a otimização é substituir o campo do atualizador por um campo de deslocamento e substituir as chamadas do atualizador por chamadas para Unsafe.
Como otimizar o Atomic*FieldUpdater
A otimização é implementada em três partes: instrumentação, substituição e limpeza.
Instrumentação
A primeira etapa é introduzir campos de deslocamento ao lado do campo do atualizador para facilitar o acesso direto pela chamada Unsafe .
static final long updater$offset = SyntheticUnsafe.UNSAFE.objectFieldOffset(Example.class.getDeclaredField("data"))
O campo é acessado por reflexão, e Unsafe é usado para extrair o deslocamento do campo na classe. Esse código representa os elementos internos de Atomic*FieldUpdater se você desconsiderar a validação de reflexão. Em vez disso, o tipo de detentor do atualizador e o tipo de campo do campo volátil são rastreados estaticamente no compilador.
O campo original e a inicialização dele são deixados como estão. O processo de otimização facilita e otimiza os usos de forma otimista e, em seguida, limpa. Essa é uma abordagem simples para a implementação, mas também permite a otimização parcial de campos do atualizador, em que alguns usos são deixados como estavam, enquanto outros são otimizados.
Substituição
Nesse ponto do compilador, após um ponto de junção de simultaneidade adequado, temos uma lista de campos de atualizador instrumentados. Isso significa que podemos otimizar cada site de chamada individualmente com base em algumas condições. Considere um exemplo de chamada:
updater.compareAndSet(holder, expectedValue, newValue);
As condições exigidas pelo Atomic*FieldUpdater são estas:
updatervem de um campo instrumentado? Ou seja, a análise estática pode rastrear o valor do objeto de volta a uma leitura de campo de um atualizador instrumentado?holderé a mesma classe ou uma subclasse do tipo de detentor definido originalmente?newValueé a mesma classe ou uma subclasse do tipo de campo definido originalmente?
Se todas as condições forem atendidas, a chamada será substituída por uma chamada para Unsafe sem nenhuma das verificações de reflexão.
SyntheticUnsafe.UNSAFE.compareAndSwapObject(holder, Example.updater$offset, expectedValue, newValue)
Essa nova chamada é mais rápida e simples, mas difere da chamada original em relação ao processamento de valores nulos em updater e holder. A menos que sejam estaticamente descartadas, as verificações de nulo são inseridas para ambas.
Limpeza
Nesse ponto, a classe de retenção tem o campo do atualizador original e o novo campo de deslocamento, além de sites de chamada que podem usar um dos dois. Se nenhum dos sites de chamada foi otimizado, o campo de deslocamento será removido. Se todos os sites de chamada foram otimizados, o campo do atualizador será removido. Em ambos os casos, a chamada de inicialização também será excluída. A exclusão de campos não utilizados e a remoção de código inativo já são feitas no compilador, mas a remoção do código de inicialização aqui exige mais alguns truques.
Tanto a chamada para newUpdater quanto getDeclaredField podem ter efeitos colaterais, já que podem gerar exceções (e a implementação delas também é desconhecida, já que depende da versão da API). Isso significa que, por otimização genérica, elas não podem ser removidas com segurança. Portanto, essa limpeza exigiu uma consideração explícita dos campos instrumentados, já que eles são estaticamente conhecidos por serem livres de exceções.
No final, o exemplo de atualizador simples mostrado acima fica assim após a otimização:
Resultados
Após essas otimizações, kotlinx.atomicfu e a maioria dos usos explícitos de AtomicInt/Long/ReferenceFieldUpdater agora correspondem à performance de AtomicReference com o R8 aplicado. Na verdade, ele é ainda mais rápido em alguns comparativos. kotlinx.atomicfu tem um plug-in do compilador que pode incorporar instâncias atomic em campos, reduzindo as alocações necessárias para criar um campo atualizado atomicamente.
O Jetpack Compose foi o principal beneficiário desse trabalho. O ambiente de execução do Compose tem vários microcomparativos que rastreiam a performance da corrotina muito de perto para detectar regressões de performance com antecedência. Quando os comparativos foram atualizados para uma nova versão do R8, notamos uma melhoria de 2 vezes ao iniciar e cancelar corrotinas em LaunchedEffect!
Além disso, a equipe do ART está implementando essas otimizações nativamente no nível da VM. Se o app for destinado à API 36 e estiver em execução em uma versão recente do Android, é possível que o dispositivo já esteja otimizando corrotinas de maneira semelhante. Os comparativos de corrotina acima observaram uma melhoria de aproximadamente 15% na performance após atualizações JIT nas versões recentes do ART.
O app vai receber essa otimização por padrão ao fazer upgrade para o AGP 9.2.0 ou usando o R8 9.2.0 diretamente. Para mais informações, consulte D8 dexer e R8 shrinker.
-
Casos de sucessoAs regressões de performance são notoriamente difíceis de reproduzir, o que as torna um gargalo enorme para desenvolvedores de dispositivos móveis.
Alice Yuan, Arti Arutiunov, Nikita Ogorodnikov • Leitura de 4 minutos -
Casos de sucessoO FotMob registrou recentemente o maior aumento de um único dia no Wear OS entre o público instalado em 5 anos, com uma média diária de 2 a 3 vezes. O segredo? Um fluxo de instalação simples entre dispositivos que ajuda os usuários a descobrir o app para Wear OS diretamente no smartphone.
Garan Jenkin • Leitura de 3 minutos -
Casos de sucessoO app de mindfulness Gratitude incentiva a consistência por meio de um microdiário diário, afirmações e quadros de visão. O app tem mais de 6 milhões de downloads, 150 mil classificações de 5 estrelas e 100 milhões de entradas de diário registradas.
Amrit Sanjeev, Ash Nohe • Leitura de 3 minutos
Receba os insights mais recentes sobre o desenvolvimento do Android na sua caixa de entrada semanalmente.