Novedades sobre productos

Compilaciones un 18% más rápidas, sin concesiones

Lectura de 8 min

El equipo de Android Runtime (ART) redujo el tiempo de compilación en un 18% sin comprometer el código compilado ni ninguna regresión de memoria máxima. Esta mejora formó parte de nuestra iniciativa de 2025 para mejorar el tiempo de compilación sin sacrificar el uso de la memoria ni la calidad del código compilado.

Optimizar la velocidad de compilación es fundamental para ART. Por ejemplo, cuando se compila justo a tiempo (JIT), afecta directamente la eficiencia de las aplicaciones y el rendimiento general del dispositivo. Las compilaciones más rápidas reducen el tiempo antes de que se activen las optimizaciones, lo que genera una experiencia del usuario más fluida y con mayor capacidad de respuesta. Además, tanto para la compilación JIT como para la AOT, las mejoras en la velocidad de compilación se traducen en un menor consumo de recursos durante el proceso de compilación, lo que beneficia la duración de la batería y la temperatura del dispositivo, especialmente en los dispositivos de gama baja.

Algunas de estas mejoras en la velocidad de compilación se lanzaron en la versión de Android de junio de 2025, y el resto estará disponible en la versión de fin de año de Android. Además, todos los usuarios de Android 12 y versiones posteriores pueden recibir estas mejoras a través de las actualizaciones de Mainline.

Cómo optimizar el compilador de optimización

La optimización de un compilador siempre es un juego de compensaciones. No puedes obtener velocidad gratis; tienes que renunciar a algo. Nos propusimos un objetivo muy claro y desafiante: acelerar el compilador, pero sin introducir regresiones de memoria y, lo que es fundamental, sin degradar la calidad del código que produce. Si el compilador es más rápido, pero las apps se ejecutan más lento, no cumplimos con nuestro objetivo.

El único recurso que estábamos dispuestos a invertir era nuestro propio tiempo de desarrollo para investigar en profundidad y encontrar soluciones inteligentes que cumplieran con estos criterios estrictos. Analicemos más de cerca cómo trabajamos para encontrar áreas de mejora y las soluciones adecuadas para los distintos problemas.

Cómo encontrar posibles optimizaciones que valgan la pena

Antes de comenzar a optimizar una métrica, debes poder medirla. De lo contrario, nunca podrás saber si la mejoraste o no. Afortunadamente, la velocidad de compilación es bastante constante, siempre y cuando tomes algunas precauciones, como usar el mismo dispositivo para medir antes y después de un cambio, y asegurarte de que el dispositivo no sufra estrangulamiento térmico. Además, también tenemos mediciones determinísticas, como las estadísticas del compilador, que nos ayudan a comprender lo que sucede en segundo plano.

 

Dado que el recurso que sacrificamos para estas mejoras fue nuestro tiempo de desarrollo, queríamos poder realizar iteraciones lo más rápido posible. Esto significó que tomamos un puñado de apps representativas (una combinación de apps propias, apps de terceros y el propio sistema operativo Android) para crear prototipos de soluciones. Más adelante, verificamos que la implementación final valía la pena con pruebas manuales y automatizadas de forma generalizada.

 

Con ese conjunto de APKs seleccionados, activaríamos una compilación manual de forma local, obtendríamos un perfil de la compilación y usaríamos pprof para visualizar en qué invertimos nuestro tiempo.

image.png

Ejemplo del gráfico tipo llama de un perfil en pprof

La herramienta pprof es muy potente y nos permite segmentar, filtrar y ordenar los datos para ver, por ejemplo, qué fases o métodos del compilador consumen la mayor parte del tiempo. No entraremos en detalles sobre pprof en sí, solo debes saber que, si la barra es más grande, significa que tomó más tiempo de compilación.

Una de estas vistas es la "de abajo hacia arriba", en la que puedes ver qué métodos consumen la mayor parte del tiempo. En la siguiente imagen, podemos ver un método llamado Kill, que representa más del 1% del tiempo de compilación. Algunos de los otros métodos principales también se analizarán más adelante en la entrada de blog.

image.png

Vista ascendente de un perfil

En nuestro compilador de optimización, hay una fase llamada numeración global de valores (GVN). No tienes que preocuparte por lo que hace en su totalidad, pero la parte relevante es saber que tiene un método llamado "Kill" que borrará algunos nodos según un filtro. Esto lleva mucho tiempo, ya que debe iterar todos los nodos y verificarlos uno por uno. Notamos que hay algunos casos en los que sabemos de antemano que la verificación será falsa, independientemente de los nodos que tengamos activos en ese momento. En estos casos, podemos omitir la iteración por completo, lo que reduce el porcentaje de 1.023% a aproximadamente el 0.3% y mejora el tiempo de ejecución de GVN en aproximadamente un 15%.

Implementar optimizaciones valiosas

Ya explicamos cómo medir y detectar dónde se invierte el tiempo, pero esto es solo el comienzo. El siguiente paso es optimizar el tiempo de compilación.

Por lo general, en un caso como el de "Kill" anterior, analizaríamos cómo iteramos a través de los nodos y lo haríamos más rápido, por ejemplo, realizando acciones en paralelo o mejorando el algoritmo en sí. De hecho, eso fue lo que intentamos al principio, y solo cuando no pudimos encontrar nada que hacer, tuvimos un momento de “Espera un momento…” y vimos que la solución era (en algunos casos) no iterar en absoluto. Cuando se realizan este tipo de optimizaciones, es fácil perder de vista el panorama general.

En otros casos, usamos varias técnicas diferentes, como las siguientes:

  • Usa métodos heurísticos para decidir si una optimización no producirá resultados valiosos y, por lo tanto, se puede omitir.
  • Usar estructuras de datos adicionales para almacenar en caché los datos calculados
  • cambiar las estructuras de datos actuales para aumentar la velocidad
  • Se calculan los resultados de forma diferida para evitar ciclos en algunos casos.
  • Usar la abstracción correcta: Las funciones innecesarias pueden ralentizar el código.
  • Evita buscar un puntero de uso frecuente en muchas cargas.

¿Cómo sabemos si vale la pena realizar las optimizaciones?

Esa es la parte genial, no lo haces. Después de detectar que un área consume mucho tiempo de compilación y de dedicar tiempo de desarrollo para intentar mejorarla, a veces no se puede encontrar una solución. Quizás no haya nada que hacer, la implementación llevará demasiado tiempo, se producirá una regresión significativa en otra métrica, aumentará la complejidad de la base de código, etcétera. Por cada optimización exitosa que veas en esta entrada de blog, ten en cuenta que hay muchas otras que no se concretaron.

Si te encuentras en una situación similar, intenta estimar cuánto mejorarás la métrica con la menor cantidad de trabajo posible. Esto significa, en orden:

  1. Estimar con una métrica que ya recopilaste o simplemente con una intuición
  2. Cómo realizar estimaciones con un prototipo rápido y sencillo
  3. Implementa una solución.

No olvides considerar la estimación de las desventajas de tu solución. Por ejemplo, si vas a usar estructuras de datos adicionales, ¿cuánta memoria estás dispuesto a usar?

Profundiza más

Sin más preámbulos, veamos algunos de los cambios que implementamos.

Implementamos un cambio para optimizar un método llamado FindReferenceInfoOf. Este método realizaba una búsqueda lineal de un vector para encontrar una entrada. Actualizamos esa estructura de datos para que se indexe por el ID de la instrucción, de modo que FindReferenceInfoOf sea O(1) en lugar de O(n). Además, preasignamos el vector para evitar el cambio de tamaño. Aumentamos ligeramente la memoria, ya que tuvimos que agregar un campo adicional que contara cuántas entradas insertamos en el vector, pero fue un pequeño sacrificio, ya que la memoria máxima no aumentó. Esto aceleró nuestra fase de LoadStoreAnalysis entre un 34% y un 66%, lo que, a su vez, genera una mejora del tiempo de compilación de entre un 0.5% y un 1.8%.

Tenemos una implementación personalizada de HashSet que usamos en varios lugares. La creación de esta estructura de datos llevaba una cantidad considerable de tiempo, y descubrimos por qué. Hace muchos años, esta estructura de datos se usaba solo en algunos lugares que utilizaban HashSets muy grandes, y se modificó para optimizarla para ese uso. Sin embargo, en la actualidad, se usa en la dirección opuesta, con pocas entradas y una vida útil corta. Esto significaba que estábamos desperdiciando ciclos al crear este enorme HashSet, pero solo lo usábamos para algunas entradas antes de descartarlo. Con este cambio, mejoramos entre un 1.3% y un 2% el tiempo de compilación. Como beneficio adicional, el uso de memoria disminuyó entre un 0.5% y un 1% aproximadamente, ya que no usamos estructuras de datos tan grandes como antes.

Mejoramos entre un 0.5% y un 1% el tiempo de compilación pasando estructuras de datos por referencia a la expresión lambda para evitar copiarlas. Esto es algo que se pasó por alto en la revisión original y permaneció en nuestra base de código durante años. Gracias a la revisión de los perfiles en pprof, notamos que estos métodos creaban y destruían muchas estructuras de datos, lo que nos llevó a investigarlos y optimizarlos.

Aceleramos la fase que escribe el resultado compilado almacenando en caché los valores calculados, lo que se tradujo en una mejora de entre el 1.3% y el 2.8% del tiempo total de compilación. Lamentablemente, la contabilidad adicional fue demasiado, y nuestras pruebas automatizadas nos alertaron sobre la regresión de memoria. Más adelante, volvimos a analizar el mismo código y, luego, implementamos una nueva versión que no solo solucionó la regresión de memoria, sino que también mejoró el tiempo de compilación en un ~0.5 a 1.8% adicional. En este segundo cambio, tuvimos que refactorizar y reimaginar cómo debería funcionar esta fase para deshacernos de una de las dos estructuras de datos.

Tenemos una fase en nuestro compilador de optimización que inserta llamadas a funciones para obtener un mejor rendimiento. Para elegir qué métodos insertar de forma intercalada, usamos tanto heurísticas antes de realizar cualquier cálculo como verificaciones finales después de trabajar, pero justo antes de finalizar la inserción intercalada. Si alguno de ellos detecta que la intercalación no vale la pena (por ejemplo, se agregarían demasiadas instrucciones nuevas), no intercalamos la llamada al método.

Trasladamos dos verificaciones de la categoría “verificaciones finales” a la categoría “heurística” para estimar si una inserción tendrá éxito o no antes de realizar cualquier cálculo que requiera mucho tiempo. Dado que se trata de una estimación, no es perfecta, pero verificamos que nuestras nuevas heurísticas abarcan el 99.9% de lo que se insertaba antes sin afectar el rendimiento. Una de estas nuevas heurísticas se relacionaba con los registros DEX necesarios (mejora de entre el 0.2% y el 1.3%), y la otra, con la cantidad de instrucciones (mejora del 2%).

Tenemos una implementación personalizada de un BitVector que usamos en varios lugares. Reemplazamos la clase BitVector redimensionable por una clase BitVectorView más simple para ciertos vectores de bits de tamaño fijo. Esto elimina algunas indirecciones y verificaciones de rango en el tiempo de ejecución, y acelera la construcción de los objetos de vectores de bits.

Además, la clase BitVectorView se convirtió en una plantilla para el tipo de almacenamiento subyacente (en lugar de usar siempre uint32_t como el antiguo BitVector). Esto permite que algunas operaciones, como Union(), procesen el doble de bits juntos en plataformas de 64 bits. Las muestras de las funciones afectadas se redujeron en más de un 1% en total cuando se compiló el SO Android. Esto se hizo en varios cambios [123456].

Si habláramos en detalle sobre todas las optimizaciones, estaríamos aquí todo el día. Si te interesan más optimizaciones, consulta otros cambios que implementamos:

Conclusión

Nuestra dedicación a mejorar la velocidad de compilación de ART ha generado mejoras significativas, lo que hace que Android sea más fluido y eficiente, y también contribuye a una mejor duración de la batería y a la temperatura del dispositivo. Al identificar e implementar optimizaciones de forma diligente, demostramos que es posible obtener ganancias significativas en el tiempo de compilación sin comprometer el uso de memoria ni la calidad del código.

Nuestro recorrido incluyó la creación de perfiles con herramientas como pprof, la disposición a realizar iteraciones y, a veces, incluso el abandono de vías menos fructíferas. Los esfuerzos colectivos del equipo de ART no solo redujeron el tiempo de compilación en un porcentaje notable, sino que también sentaron las bases para futuros avances.

Todas estas mejoras están disponibles en la actualización de Android de fin de año 2025 y para Android 12 y versiones posteriores a través de las actualizaciones de mainline. Esperamos que este análisis detallado de nuestro proceso de optimización proporcione estadísticas valiosas sobre las complejidades y los beneficios de la ingeniería de compiladores.

Seguir leyendo