Estudos de caso

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:

pic01_enhanced.png
Rastreamento de método LaunchedEffect visualizado na interface do Perfetto

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?

pic02-enhanced.png
Análise detalhada do rastreamento de método de AtomicReferenceFieldUpdater.get durante a inicialização de LaunchedEffect

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 updater vem 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.

pic03_enhanced.png
Gráfico de comparativo de mercado que ilustra o tempo necessário para iniciar e cancelar corrotinas no LaunchedEffect (quanto menor, melhor). A mudança no gráfico corresponde a uma atualização do R8, mostrando uma melhoria de 2x.

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.

Escrito por:
Continue lendo