Notícias sobre produtos

Compilações 18% mais rápidas, sem compromissos

Leitura de 8 minutos

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 AOT, as melhorias na velocidade de compilação se traduzem em um consumo reduzido 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 de junho de 2025 do Android, e o restante estará disponível na versão de fim de ano do Android. Além disso, todos os usuários do Android nas versões 12 e mais recentes podem receber essas melhorias por atualizações da linha principal.

Otimizar o compilador de otimização

A otimização de um compilador é sempre uma troca. Não é possível ter velocidade sem custo financeiro. É preciso abrir mão de algo. Definimos um objetivo muito claro e desafiador 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 produzido. 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, você precisa medi-la. Caso contrário, você nunca terá certeza se 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 garantir que o dispositivo não esteja com limitação térmica. Além disso, também temos medições determinísticas, como estatísticas do compilador, que nos 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 que 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.

image.png

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. 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 você pode ver quais métodos estão consumindo mais tempo. Na imagem abaixo, podemos ver 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 abordados mais adiante na postagem do blog.

image.png

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 consome muito tempo, já que precisa 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 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 em si. Na verdade, foi isso que tentamos no início. 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 perseguir 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 de 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, a implementação leve muito tempo, outra métrica seja significativamente regredida, a complexidade da base de código aumente etc. Para cada otimização bem-sucedida que você vê 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:

  1. Estimar com métricas que você já coletou ou apenas com base em uma intuição
  2. Estimativa com um protótipo rápido e simples
  3. 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, resulta em uma melhoria de 0,5 a 1,8% no tempo de compilação.

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 significava que estávamos desperdiçando ciclos ao criar esse HashSet enorme, mas só o usávamos 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. Ao analisar os perfis no pprof, percebemos que esses métodos estavam criando e destruindo muitas estruturas de dados, o que nos levou a investigar e otimizar.

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 inline 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 [123456]

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:

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 melhor duração da bateria e temperatura do dispositivo. Ao identificar e implementar otimizações com diligência, 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 abriram caminho 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 esta análise detalhada do nosso processo de otimização forneça insights valiosos sobre as complexidades e recompensas da engenharia de compiladores.

Escrito por:

Continuar lendo