A equipe do Android Runtime (ART) reduziu o tempo de compilação em 18% sem comprometer o código compilado ou regressões de memória de pico. Essa melhoria fez parte da nossa iniciativa de 2025 para reduzir o tempo de compilação sem sacrificar o uso da memória ou a qualidade do código compilado.
Otimizar a velocidade de compilação é crucial para o ART. Por exemplo, ao compilar just-in-time (JIT), isso afeta diretamente a eficiência dos aplicativos e o desempenho geral do dispositivo. Compilações mais rápidas reduzem o tempo antes do início das otimizações, resultando em uma experiência do usuário mais suave e responsiva. Além disso, tanto para JIT quanto para ahead-of-time (AOT), as melhorias na velocidade de compilação se traduzem em menor consumo de recursos durante o processo de compilação, beneficiando a duração da bateria e a temperatura do dispositivo, especialmente em dispositivos de baixo custo.
Algumas dessas melhorias de velocidade de compilação foram lançadas na versão do Android de junho de 2025, e o restante estará disponível na versão do Android do fim do ano. Além disso, todos os usuários do Android nas versões 12 e mais recentes podem receber essas melhorias por meio de atualizações da linha principal.
Otimizar o compilador de otimização
A otimização de um compilador é sempre uma troca. Não é possível ganhar velocidade sem custo financeiro. É preciso abrir mão de algo. Definimos uma meta muito clara e desafiadora para nós mesmos: tornar o compilador mais rápido, mas sem introduzir regressões de memória e, principalmente, sem degradar a qualidade do código que ele produz. Se o compilador for mais rápido, mas os apps forem executados mais lentamente, falhamos.
O único recurso que estávamos dispostos a gastar era nosso próprio tempo de desenvolvimento para investigar e encontrar soluções inteligentes que atendessem a esses critérios rigorosos. Vamos analisar mais de perto como trabalhamos para encontrar áreas de melhoria e as soluções certas para os vários problemas.
Encontrar possíveis otimizações úteis
Antes de começar a otimizar uma métrica, é preciso medi-la. Caso contrário, você nunca terá certeza se ela melhorou ou não. Felizmente, a velocidade do tempo de compilação é bastante consistente, desde que você tome algumas precauções, como usar o mesmo dispositivo para medir antes e depois de uma mudança e evitar o estrangulamento térmico do dispositivo. Além disso, também temos medições determinísticas, como estatísticas do compilador, que ajudam a entender o que está acontecendo nos bastidores.
Como o recurso que estávamos sacrificando para essas melhorias era nosso tempo de desenvolvimento, queríamos poder iterar o mais rápido possível. Isso significa que pegamos alguns apps representativos (uma mistura de apps próprios, de terceiros e o próprio sistema operacional Android) para criar protótipos de soluções. Depois, verificamos se a implementação final valeu a pena com testes manuais e automatizados de forma generalizada.
Com esse conjunto de APKs escolhidos a dedo, acionamos uma compilação manual localmente, recebemos um perfil da compilação e usamos o pprof para visualizar onde estamos gastando nosso tempo.
Exemplo de um gráfico de chama de perfil em pprof
A ferramenta pprof é muito eficiente e permite segmentar, filtrar e classificar os dados para ver, por exemplo, quais fases ou métodos do compilador estão levando mais tempo. Não vamos entrar em detalhes sobre o pprof em si. Basta saber que, se a barra for maior, significa que levou mais tempo da compilação.
Uma dessas visualizações é a "de baixo para cima", em que é possível ver quais métodos estão levando mais tempo. Na imagem abaixo, vemos um método chamado "Kill", que representa mais de 1% do tempo de compilação. Alguns dos outros métodos principais também serão discutidos mais adiante na postagem do blog.
Visualização de baixo para cima de um perfil
No nosso compilador de otimização, há uma fase chamada numeração global de valores (GVN, na sigla em inglês). Você não precisa se preocupar com o que ele faz como um todo, mas a parte relevante é saber que ele tem um método chamado "Kill" que exclui alguns nós de acordo com um filtro. Isso leva tempo, já que é preciso iterar por todos os nós e verificar um por um. Notamos que há alguns casos em que sabemos com antecedência que a verificação será falsa, não importa os nós ativos naquele momento. Nesses casos, podemos pular a iteração completamente, reduzindo a taxa de 1,023% para cerca de 0,3% e melhorando o tempo de execução do GVN em cerca de 15%.
Implementar otimizações úteis
Já falamos sobre como medir e detectar onde o tempo está sendo gasto, mas isso é só o começo. A próxima etapa é otimizar o tempo gasto na compilação.
Normalmente, em um caso como o "Kill" acima, analisamos como iteramos pelos nós e fazemos isso mais rápido, por exemplo, fazendo as coisas em paralelo ou melhorando o algoritmo. Na verdade, foi isso que tentamos fazer primeiro. Só quando não encontramos nada para fazer é que tivemos um momento de "Espere um pouco…" e percebemos que a solução era (em alguns casos) não iterar! Ao fazer esse tipo de otimização, é fácil perder o foco.
Em outros casos, usamos algumas técnicas diferentes, incluindo:
- usar heurísticas para decidir se uma otimização não vai gerar resultados úteis e, portanto, pode ser ignorada
- usando estruturas de dados extras para armazenar em cache os dados computados
- mudar as estruturas de dados atuais para aumentar a velocidade;
- computar resultados de forma lenta para evitar ciclos em alguns casos
- use a abstração certa: recursos desnecessários podem deixar o código mais lento
- evitar buscar um ponteiro usado com frequência em muitos carregamentos
Como saber se vale a pena fazer as otimizações?
Essa é a parte legal: você não precisa. Depois de detectar que uma área está consumindo muito tempo de compilação e dedicar tempo de desenvolvimento para tentar melhorar isso, às vezes não é possível encontrar uma solução. Talvez não haja nada a fazer, vai demorar muito para implementar, vai regredir outra métrica significativamente, aumentar a complexidade da base de código etc. Para cada otimização bem-sucedida que você pode ver nesta postagem do blog, saiba que há inúmeras outras que não deram certo.
Se você estiver em uma situação semelhante, tente estimar o quanto vai melhorar a métrica fazendo o mínimo de trabalho possível. Isso significa, em ordem:
- Estimativa com base em métricas já coletadas ou apenas em uma intuição
- Estimativa com um protótipo rápido e simples
- Implemente uma solução.
Não se esqueça de estimar as desvantagens da sua solução. Por exemplo, se você vai usar estruturas de dados extras, quanta memória está disposto a usar?
Análise detalhada
Sem mais delongas, vamos conferir algumas das mudanças que implementamos.
Implementamos uma mudança para otimizar um método chamado FindReferenceInfoOf. Esse método fazia uma pesquisa linear de um vetor para encontrar uma entrada. Atualizamos essa estrutura de dados para ser indexada pelo ID da instrução. Assim, FindReferenceInfoOf seria O(1) em vez de O(n). Além disso, pré-alocamos o vetor para evitar o redimensionamento. Aumentamos um pouco a memória porque tivemos que adicionar um campo extra que contava quantas entradas inserimos no vetor, mas foi um pequeno sacrifício, já que o pico de memória não aumentou. Isso acelerou nossa fase LoadStoreAnalysis em 34 a 66%, o que, por sua vez, melhora o tempo de compilação em 0,5 a 1,8%.
Temos uma implementação personalizada do HashSet que usamos em vários lugares. A criação dessa estrutura de dados estava levando um tempo considerável, e descobrimos o motivo. Há muitos anos, essa estrutura de dados era usada em apenas alguns lugares que usavam HashSets muito grandes, e ela foi ajustada para ser otimizada para isso. No entanto, hoje em dia, ele é usado na direção oposta, com apenas algumas entradas e um ciclo de vida curto. Isso significa que estávamos desperdiçando ciclos ao criar esse HashSet enorme, mas só o usamos para algumas entradas antes de descartá-lo. Com essa mudança, melhoramos o tempo de compilação em cerca de 1,3 a 2%. Além disso, o uso da memória diminuiu em cerca de 0,5 a 1%, já que não estávamos usando estruturas de Big Data tão grandes quanto antes.
Melhoramos de 0,5 a 1% do tempo de compilação transmitindo estruturas de dados por referência à lambda para evitar a cópia delas. Isso passou despercebido na análise original e ficou na nossa base de código por anos. Foi ao analisar os perfis no pprof que percebemos que esses métodos estavam criando e destruindo muitas estruturas de dados, o que nos levou a investigar e otimizá-los.
Aceleramos a fase que grava a saída compilada armazenando em cache os valores calculados, o que resultou em uma melhoria de aproximadamente 1,3 a 2,8% no tempo total de compilação. Infelizmente, a contabilidade extra foi demais, e nossos testes automatizados nos alertaram sobre a regressão de memória. Depois, analisamos o mesmo código e implementamos uma nova versão que não apenas corrigiu a regressão de memória, mas também melhorou o tempo de compilação em mais 0,5 a 1,8%. Nessa segunda mudança, tivemos que refatorar e reimaginar como essa fase deveria funcionar para eliminar uma das duas estruturas de dados.
Temos uma fase no nosso compilador de otimização que inverte as chamadas de função para melhorar o desempenho. Para escolher quais métodos inserir inline, usamos heurísticas antes de fazer qualquer computação e verificações finais depois de trabalhar, mas antes de finalizar a inserção inline. Se algum deles detectar que o inlining não vale a pena (por exemplo, muitas novas instruções seriam adicionadas), não vamos deixar in-line a chamada de método.
Movemos duas verificações da categoria "verificações finais" para a categoria "heurística" para estimar se uma incorporação vai ser bem-sucedida ou não antes de fazer qualquer cálculo caro em termos de tempo. Como essa é uma estimativa, ela não é perfeita, mas verificamos que nossas novas heurísticas cobrem 99,9% do que estava em linha antes, sem afetar o desempenho. Uma dessas novas heurísticas era sobre os registros DEX necessários (melhoria de ~0,2 a 1,3%), e a outra sobre o número de instruções (melhoria de ~2%).
Temos uma implementação personalizada de um BitVector que usamos em vários lugares. Substituímos a classe BitVector redimensionável por uma BitVectorView mais simples para determinados vetores de bits de tamanho fixo. Isso elimina algumas indireções e verificações de intervalo de tempo de execução e acelera a construção dos objetos de vetor de bits.
Além disso, a classe BitVectorView foi criada com base no tipo de armazenamento subjacente, em vez de sempre usar uint32_t como o BitVector antigo. Isso permite que algumas operações, por exemplo, Union(), processem o dobro de bits juntos em plataformas de 64 bits. As amostras das funções afetadas foram reduzidas em mais de 1% no total ao compilar o SO Android. Isso foi feito em várias mudanças [1, 2, 3, 4, 5, 6]
Se falássemos em detalhes sobre todas as otimizações, ficaríamos aqui o dia todo. Se você quiser mais otimizações, confira outras mudanças que implementamos:
- Adicione contabilidade para melhorar os tempos de compilação em cerca de 0,6 a 1,6%.
- Calcule os dados de forma lenta para evitar ciclos, se possível.
- Refatore o código para pular o trabalho de pré-computação quando ele não for usado.
- Evite algumas cadeias de carga dependentes quando o alocador puder ser facilmente obtido de outros lugares.
- Outro caso de adição de uma verificação para evitar trabalho desnecessário.
- Evite ramificações frequentes no tipo de registro (núcleo/FP) no alocador de registros.
- Verifique se alguns arrays foram inicializados no tempo de compilação. Não dependa do clang para fazer isso.
- Limpe alguns loops. Use loops de intervalo que o clang pode otimizar melhor porque não precisa recarregar os ponteiros internos do contêiner devido a efeitos colaterais do loop. Evite chamar a função virtual "HInstruction::GetInputRecords()" no loop usando "InputAt(.)" in-line para cada entrada.
- Evite funções Accept() para o padrão de visitante usando uma otimização do compilador.
Conclusão
Nossa dedicação a melhorar a velocidade de compilação do ART resultou em melhorias significativas, tornando o Android mais fluido e eficiente, além de contribuir para uma vida útil melhor da bateria e para a temperatura do dispositivo. Ao identificar e implementar otimizações de forma diligente, demonstramos que é possível ter ganhos substanciais no tempo de compilação sem comprometer o uso da memória ou a qualidade do código.
Nossa jornada envolveu a criação de perfis com ferramentas como o pprof, a disposição para iterar e, às vezes, até mesmo abandonar caminhos menos frutíferos. Os esforços coletivos da equipe do ART não apenas reduziram o tempo de compilação em uma porcentagem notável, mas também estabeleceram as bases para avanços futuros.
Todas essas melhorias estão disponíveis na atualização de fim de ano do Android de 2025 e para o Android 12 e versões mais recentes por meio de atualizações principais. Esperamos que essa análise detalhada do nosso processo de otimização forneça insights valiosos sobre as complexidades e recompensas da engenharia de compiladores.
Continuar lendo
-
Notícias sobre produtos
De sobreposições aumentadas a ambientes totalmente imersivos, o ecossistema Android XR está se expandindo rapidamente, e o Samsung Galaxy XR já está disponível.
Stevan Silva, Vinny DaSilva • Leitura de 3 minutos
-
Notícias sobre produtos
Todos os anos, o Google I/O traz novos anúncios e recursos em ecossistemas e produtos, incluindo o desenvolvimento do Android. À medida que o desenvolvimento muda para IA e ferramentas assistidas por agentes, ampliamos nossas ofertas para oferecer um suporte melhor, seja qual for sua decisão de criar para Android.
Simona Milanovic • Leitura de 2 minutos
-
Notícias sobre produtos
No Google I/O 2026, mostramos como os avanços mais recentes no ecossistema Android podem ajudar você a aumentar a qualidade do app e maximizar a eficiência do desenvolvimento.
Ataul Munim • Leitura de 3 minutos
Fique por dentro
Receba os insights mais recentes sobre desenvolvimento Android na sua caixa de entrada semanalmente.