Android

Blog do Desenvolvedor Android: Como o R8 tornou as Kotlin Coroutines no Android 2x mais rápidas

O blog do Android Developers anunciou melhorias significativas na velocidade de corrotinas Kotlin no Android graças ao otimizador R8, que torna o lançamento e cancelamento de corrotinas até 2x mais rápido a partir da versão AGP 9.2.0. Essas melhorias são especialmente importantes para bibliotecas como kotlinx.coroutines e Jetpack Compose, onde as corrotinas podem se tornar um gargalo de desempenho em operações fora do processo de composição.

Compartilhar
Android Developers Blog: How R8 made Kotlin Coroutines on Android 2x faster
Postado por Andrei Shikov, Engenheiro de Software Sênior, Android Toolkit e Jonathan Starup, Engenheiro de Software, Equipe R8

A partir da versão AGP 9.2.0, o R8 otimiza a maioria das chamadas Atomic*FieldUpdater para variantes do Unsafe que realizam operações 2x a 4x mais rápidas em operações comuns. Isso tem um impacto particularmente grande na biblioteca kotlinx.atomicfu, que implementa atomics para kotlinx.coroutines, tornando o lançamento e cancelamento de coroutines até 2x mais rápido. Para obter os benefícios, atualize seu AGP para a versão 9.2.0 ou superior.

Com a maioria dos aplicativos Android adotando Kotlin como sua principal linguagem de escolha, kotlinx.coroutines se tornou uma norma de fato para programação assíncrona. A biblioteca oferece um modo bem projetado e estruturado de gerenciar fluxos concorrentes que é nativo ao Kotlin. O Jetpack Compose não foi exceção, adotando coroutines para gerenciar eventos de ponteiro, animações e outras interações. Na época da escrita deste artigo, a maioria das APIs concorrentes no Compose chama funções suspend internamente e lança e/ou cancela coroutines para lidar com atualizações.

Ao começar a investigar desempenho, a equipe do Compose descobriu que as coroutines eram um gargalo para muitas operações que ocorrem fora da composição. Como exemplo, 80% do tempo gasto na criação e atualização de Modifier.clickable foi consumido por lançamento e cancelamento de coroutines internas que lidavam com atualizações de InteractionSource. Com base nessas observações, grande parte do trabalho inicial de desempenho focou em remover coroutines do caminho padrão e adiar a inicialização até ser necessário.

O custo de uma coroutine

A maneira mais fácil de analisar o comportamento interno de uma função no Android é capturar um rastreamento de método da Máquina Virtual do Android (ART). Um rastreamento de método ART é uma ferramenta que registra a execução de fluxo de um aplicativo, mostrando exatamente quais métodos são chamados, sua ordem e quanto tempo é gasto em cada um, permitindo aos desenvolvedores identificar gargalos de desempenho. Para uma chamada vazia LaunchedEffect { }, seria algo como isso:

Rastreamento de método LaunchedEffect visualizado na interface do usuário do Perfetto

O rastreamento de método acima pode ser separado em três partes:

  • Inicializando uma nova coroutine
  • Startando a coroutine
  • Concluindo a coroutine (porque ela sai imediatamente)

O cancelamento LaunchedEffect é semelhante à conclusão normal, exceto que também cria uma exceção de cancelamento.

No perfil acima, algo que chama a atenção logo de cara são as chamadas frequentes para java.util.concurrent.AtomicReferenceFieldUpdater (caixas roxas ou verdes com j… rótulos). Embora cada chamada seja relativamente rápida, a frequência é preocupante; qualquer sobrecarga não negligenciável que seja espalhada por várias invocações pode se acumular para uma regressão perceptível. Ao ampliar uma chamada, percebe-se que a maior parte do tempo é gasto em... verificações de reflexão?

Uma visão detalhada do rastreamento de método AtomicReferenceFieldUpdater.get durante a inicialização da LaunchedEffect

Emissoras de rotinas assíncronas implementam uma estrutura de árvore sem bloqueio para relacionamentos pai-filho que torna a concorrência estruturada possível. Acaba sendo que a biblioteca kotlinx.atomicfu implementa operações atômicas sem bloqueio usando um primitivo conhecido da JVM, o AtomicReferenceFieldUpdater. O atualizador usa uma referência de classe e um nome de campo para realizar operações atômicas em tempo de execução, e deve executar várias verificações de segurança reflexivas para garantir que o campo exista e seja acessível. Cada operação nas rotinas (inicialização, suspensão, cancelamento, conclusão) chama pelo menos uma operação atômica, então se ela for lenta, as rotinas não desempenharão bem.

Investigando AtomicReferenceFieldUpdater

Mas vamos nos manter calmos. O AtomicReferenceFieldUpdater é na verdade bem otimizado em JVM há mais de 10 anos, e rastreamentos de método podem capturar sobrecarga que é completamente removida por uma otimização do nível da VM: compilação just-in-time (JIT) ou ahead-of-time (AOT). Para verificar o desempenho, vamos escrever alguns benchmarks 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)
        }
    }
    
    /* medindo outros métodos dos rastreamentos de método acima */
}

Executando este benchmark em um Pixel 5 (enquanto garante que AtomicReferenceFieldUpdater#compareAndSet é compilado JIT durante o aquecimento), resulta nos 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 claramente sendo aproximadamente 2,7 vezes mais lenta. Isso confirma que o ART não realiza nenhuma otimização oculta e verificações de acesso reflexivo adicionam sobrecarga real durante a execução.

Vendo para trás no rastreamento de método original, o único trabalho significativo realizado pelo AtomicReferenceFieldUpdater é a chamada interna em Unsafe.getObjectVolatile que realmente executa a operação atômica subjacente. Em muitos casos, o inicializador do atualizador é estático e pode ser comprovado sempre estar correto com base na estrutura da classe circundante. Portanto, poderia-se analisar estaticamente a maioria dos usos de AtomicReferenceFieldUpdater e substituí-los por uma variante interna Unsafe durante a compilação. Também acontece que o conjunto de ferramentas de construção do Android tem seu próprio compilador otimizador que pode fazer exatamente isso.

Otimização com R8

As classes Atomic*FieldUpdater suportam uso sutil, dinâmico e baseado em reflexão, mas são frequentemente usadas em padrões estáticamente óbvios. Isso explica tanto o desempenho de baseline lento quanto a necessidade de otimização. O R8 é um compilador otimizador para todo o programa e está bem adaptado para ver através dos padrões mais simples para cortar 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 legibilidade esses exemplos são apresentados em sintaxe Java. É por isso que não há argumentos de tipo para AtomicReferenceFieldUpdater.

class Exemplo {
    volatile String dados = "";
    static final AtomicReferenceFieldUpdater atualizador =
        AtomicReferenceFieldUpdater.newUpdater(Exemplo.class, String.class, "dados");

    void exemplo() {
        // ...
        atualizador.compareAndSet(this, "", "novo");
        // ...
    }
}

O exemplo básico cria um atualizador estático final que acessa um campo volátil com argumentos constantes simples para o holder, o tipo e o nome do campo. A reflexão usada é totalmente transparente. É claro que este atualizador se refere a um campo válido e que o local da criação do atualizador tem acesso válido ao campo.

No seu essência, Atomic*FieldUpdater é uma camada em torno de um offset de campo e chamadas para Unsafe. O melhor cenário para a otimização é substituir o campo do atualizador por um campo de offset e substituir as chamadas ao atualizador com chamadas para Unsafe.

Otimizando Atomic*FieldUpdater

A otimização é implementada em três partes: Instrumentação, Substituição e Limpeza.

Instrumentação

A primeira etapa é introduzir campos de offset ao lado do campo atualizador para facilitar o acesso direto via a chamada Unsafe.

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

O campo é acessado via reflexão, e Unsafe é usado para extrair o offset do campo na classe. Este código representa os internos de Atomic*FieldUpdater se você desconsiderar a validação de reflexão. Em vez disso, o tipo holder do atualizador e o tipo do campo volátil são rastreados estáticamente no compilador.

Note que o campo original e sua inicialização permanecem como estavam. O processo de otimização facilita e otimiza os usos de forma otimista e, em seguida, limpa. Esta é uma abordagem simples para a implementação mas também permite a otimização parcial dos campos atualizador, onde alguns usos permanecem como estavam enquanto outros são otimizados.

Substituição

A este ponto no compilador, após um ponto de junção de concorrência adequado, temos uma lista de campos atualizador instrumentados. Isso significa que podemos otimizar cada local de chamada individualmente com base em algumas condições. Considere o exemplo de chamada:

atualizador.compareAndSet(holder, valorEsperado, novoValor);

As condições que Atomic*FieldUpdater requerem são essas:

  • O atualizador vem de um campo instrumentado? Ou seja, a análise estática pode rastrear o valor do objeto de volta à leitura de um campo de um atualizador instrumentado?
  • O holder é da mesma classe ou uma subclasse do tipo holder originalmente definido?
  • O novoValor é da mesma classe ou uma subclasse do tipo campo originalmente definido?

Se todas as condições forem atendidas, então a chamada é substituída por uma chamada para

Unsafe

sem nenhuma das verificações de reflexão.

SyntheticUnsafe.UNSAFE.compareAndSwapObject(holder, Exemplo.atualizador$offset, valorEsperado, novoValor)

A nova chamada é mais rápida e simples mas difere da chamada original em relação ao seu tratamento de valores nulos em atualizador e holder. A menos que estáticamente excluídos, verificações de valor nulo são inseridas para ambos.

Limpeza

A este ponto, a classe mantenedora tem o campo atualizador original e o novo campo offset junto com locais de chamada que podem usar um ou outro dos dois. Se nenhum dos locais de chamada foi otimizado, então o campo offset deve ser removido e se todos os locais de chamada foram otimizados, então o campo atualizador deve ser removido. Em ambos os casos a inicialização da chamada também deve ser excluída. A exclusão de campos não usados e remoção de código morto já é feita no compilador, mas remover o código inicializador aqui requer alguns truques adicionais.

Ao chamar newUpdater e getDeclaredField podem ter efeitos colaterais pois podem lançar exceções (e sua implementação também é desconhecida, já que depende da versão da API). Isso significa que por otimização genérica eles não podem ser removidos de forma segura. Portanto esta limpeza exigiu considerações explícitas dos campos instrumentados, uma vez que esses são estáticamente conhecidos como livres de exceções.

No final, o simples exemplo de atualizador mostrado acima parece assim após a otimização:

Resultados

Depois dessas otimizações, kotlinx.atomicfu e a maioria dos usos explícitos de AtomicInt/Long/ReferenceFieldUpdater agora correspondem à performance do AtomicReference quando o R8 é aplicado. Na verdade, em alguns benchmarks, é ainda mais rápido; o kotlinx.atomicfu tem um plugin de compilador que pode inlinear 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 runtime do Compose tem vários microbenchmarking que acompanham de perto a performance das coroutines para detectar regressões de performance cedo. Quando os benchmarks foram atualizados para uma nova versão do R8, notamos uma melhoria de 2x ao lançar e cancelar coroutines em LaunchedEffect!

Grafico de benchmark ilustrando o tempo gasto ao iniciar e cancelar coroutines em LaunchedEffect (menor é melhor). A mudança no gráfico corresponde à atualização do R8, mostrando uma melhoria de 2x.

Além disso, a equipe ART está implementando essas otimizações nativamente no nível da VM. Se seu app estiver visando API 36 e estiver rodando em uma versão recente do Android, é possível que o seu dispositivo já esteja otimizando coroutines de maneira similar. Os benchmarks de coroutine acima observaram ~15% de melhoria na performance após atualizações JIT nas últimas versões do ART.

Seu app receberá essa otimização automaticamente ao atualizar para AGP 9.2.0 ou usando o R8 9.2.0 diretamente. Para mais informações, consulte D8 dexer e R8 shrinker.

Fonte original

Conteúdo traduzido e adaptado pela redação do Notícias Mobile. Confira também a matéria na fonte original.

Leia a matéria completa