Como o R8 tornou as corrotinas do Kotlin no Android duas vezes mais rápidas
Leitura de 7 min
A partir do AGP 9.2.0, o R8 otimiza a maioria das chamadas Atomic*FieldUpdater em variantes Unsafe que têm um desempenho 2 a 4 vezes melhor em operações comuns. Isso tem um impacto particularmente grande na biblioteca kotlinx.atomicfu, que implementa atômicos 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.
Como a maioria dos apps Android adota o Kotlin como linguagem principal, 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 em que este artigo foi escrito, a maioria das APIs simultâneas no Compose chamava funções suspend em segundo plano e iniciava e/ou cancelava corrotinas para processar atualizações.
Quando a equipe do Compose começou a investigar o desempenho, as corrotinas foram identificadas 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 desempenho foi focado em remover as corrotinas do caminho padrão e atrasar a 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 desempenho. Para uma chamada LaunchedEffect { } vazia, seria algo assim:
O rastreamento de método acima pode ser separado em três partes:
- Inicializar uma nova corrotina
- Iniciando corrotina
- Conclusão da corrotina (porque ela é encerrada imediatamente)
O cancelamento de LaunchedEffect é semelhante à conclusão normal, mas também cria um 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 negligenciável que seja distribuída em várias invocações pode resultar em uma regressão perceptível. Ao ampliar uma chamada, vemos que a maior parte do tempo é gasta 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. Acontece que a biblioteca kotlinx.atomicfu implementa operações atômicas sem bloqueio usando um primitivo conhecido da JVM, 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 (iniciar, suspender, cancelar, concluir) chama pelo menos uma operação atômica. Portanto, se ela for lenta, as corrotinas não terão um bom desempenho.
Investigação do AtomicReferenceFieldUpdater
Mas não vamos nos precipitar. O AtomicReferenceFieldUpdater é bem otimizado na JVM há mais de 10 anos, e os rastreamentos de método podem capturar overheads que são completamente removidos por uma otimização no nível da VM: compilações just-in-time (JIT) ou ahead-of-time (AOT). Para verificar o desempenho, vamos escrever algumas comparações para medir a diferença entre as 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 de mercado em um Pixel 5 (garantindo que AtomicReferenceFieldUpdater#compareAndSet seja compilado por 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 diferença, 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 uma sobrecarga real durante o tempo de execução.
Analisando o rastreamento de método original, o único trabalho significativo realizado pelo AtomicReferenceFieldUpdater é a chamada interna para Unsafe.getObjectVolatile, que realmente executa a operação atômica. 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 interna de Unsafe durante a compilação. Por acaso, o conjunto de ferramentas de build do Android tem um compilador de otimização próprio que faz exatamente isso.
Otimização com o R8
As classes Atomic*FieldUpdater oferecem suporte ao uso sutil, dinâmico e baseado em reflexão, mas geralmente são usadas em padrões estaticamente óbvios. Isso explica o desempenho lento da referência e o desejo de otimização. O R8 é um compilador de otimização de programa completo e é adequado para analisar os padrões mais simples e reduzir a sobrecarga das verificações de segurança reflexivas. O R8 recebe o bytecode da JVM após o compilador Java ou Kotlin, mas, para facilitar a leitura, esses exemplos são apresentados na sintaxe Java. Por isso, 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 básico 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 de 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 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, faz a limpeza. Essa é uma abordagem simples para a implementação, mas também permite a otimização parcial dos campos do atualizador, em que alguns usos são mantidos 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 atualização 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 que o Atomic*FieldUpdater exige são as seguintes:
- O
updatervem de um campo instrumentado? Ou seja, a análise estática pode rastrear o valor do objeto até 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 original em relação ao processamento de valores nulos em updater e holder. A menos que sejam descartadas estaticamente, as verificações de nulo são inseridas para ambas.
Limpeza
Nesse ponto, a classe de retenção tem o campo de 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 tiver sido otimizado, o campo de deslocamento precisará ser removido. Se todos os sites de chamada tiverem sido otimizados, o campo do atualizador precisará ser removido. Em ambos os casos, a chamada de inicialização também precisa ser excluída. A exclusão de campos não utilizados e a remoção de código morto já são feitas no compilador, mas a remoção do código de inicialização aqui requer mais alguns truques.
As chamadas para newUpdater e getDeclaredField podem ter efeitos colaterais, já que podem gerar exceções, e a implementação delas também é desconhecida, porque depende da versão da API. Isso significa que, por otimização genérica, eles não podem ser removidos com segurança. Portanto, essa limpeza exigiu uma consideração explícita dos campos instrumentados, já que eles são estaticamente conhecidos por não terem 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 ao desempenho de AtomicReference com o R8 aplicado. Na verdade, ele é ainda mais rápido em alguns comparativos de mercado. O kotlinx.atomicfu tem um plug-in de 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 beneficiado por esse trabalho. O ambiente de execução do Compose tem várias microcomparações que monitoram o desempenho da corrotina de perto para detectar regressões de desempenho com antecedência. Quando os comparativos de mercado foram atualizados para uma nova versão do R8, notamos uma melhoria de 2x ao iniciar e cancelar corrotinas no LaunchedEffect.
Além disso, a equipe do ART está implementando essas otimizações de forma nativa no nível da VM. Se o app segmentar a API 36 e estiver em execução em uma versão recente do Android, é possível que o dispositivo já esteja otimizando as corrotinas de maneira semelhante. Os comparativos de corrotinas acima observaram uma melhoria de aproximadamente 15% no desempenho após as atualizações do JIT nas versões recentes do ART.
Seu app vai receber essa otimização por padrão ao fazer upgrade para o AGP 9.2.0 ou ao usar o R8 9.2.0 diretamente. Para mais informações, consulte Dexer D8 e shrinker R8.
-
Estudos de casoO Grab é o principal superapp do Sudeste Asiático, oferecendo serviços de entrega, mobilidade e serviços financeiros para milhões de usuários em oito países em um único app.
Ajesh Pai, Nguyen Van Minh, Tracy Agyemang • Leitura de 5 min -
Estudos de casoA equipe criou uma base de código de interface nativa de IA que é 50% menor do que a implementação original, além de reduzir em 35% o tempo de execução do agente de IA, em 32% as trocas entre engenheiros e agentes e em 33% o custo de tokens.
Pavlo Stavytskyi, Rebecca Franks • Leitura de 11 min -
Estudos de casoO WhatsApp é a maior plataforma de mensagens do mundo, atendendo a bilhões de usuários em todo o mundo. É a ferramenta de comunicação padrão para pessoas em diversas regiões, conectando usuários por mensagens privadas, confiáveis e seguras.
Niharika Arora, Tracy Agyemang, Mayank Jain • Leitura de 8 min
Receba os insights mais recentes sobre o desenvolvimento para Android na sua caixa de entrada toda semana.