Cómo R8 hizo que las corrutinas de Kotlin en Android fueran el doble de rápidas
Lectura de 7 min
A partir de AGP 9.2.0, R8 optimiza la mayoría de las llamadas a Atomic*FieldUpdater en variantes Unsafe que tienen un rendimiento entre 2 y 4 veces mejor en las operaciones comunes. Esto tiene un impacto particularmente grande en la biblioteca kotlinx.atomicfu que implementa operaciones atómicas para kotlinx.coroutines, lo que hace que el inicio y la cancelación de corrutinas sean hasta 2 veces más rápidos. Para obtener los beneficios, actualiza tu AGP a la versión 9.2.0 o una posterior.
Dado que la mayoría de las apps para Android adoptan Kotlin como su lenguaje principal, kotlinx.coroutines se convirtió en un estándar de facto para la programación asíncrona. La biblioteca ofrece una forma bien diseñada y estructurada de administrar flujos simultáneos que es nativa de Kotlin. Jetpack Compose no fue la excepción, ya que adoptó corrutinas para administrar eventos de puntero, animaciones y otras interacciones. En el momento de escribir este artículo, la mayoría de las APIs simultáneas de Compose llaman a funciones suspend de forma interna y, además, inician o cancelan corrutinas para controlar las actualizaciones.
A medida que el equipo de Compose comenzó a investigar el rendimiento, se descubrió que las corrutinas eran un cuello de botella para muchas operaciones que ocurren fuera de la composición. Por ejemplo, el 80% del tiempo dedicado a crear y actualizar Modifier.clickable se consumió en iniciar y cancelar corrutinas internas que controlaban las actualizaciones de InteractionSource. Según esas observaciones, gran parte del trabajo de rendimiento inicial se centró en quitar las corrutinas de la ruta predeterminada y retrasar la inicialización hasta que fuera necesario.
El costo de una corrutina
La forma más sencilla de analizar el comportamiento interno de una función en Android es capturar un seguimiento de método de Android Runtime (ART). Un seguimiento de método de ART es una herramienta que registra el flujo de ejecución de una app y muestra exactamente a qué métodos se llama, su orden y cuánto tiempo se dedica a cada uno, lo que permite que los desarrolladores identifiquen cuellos de botella en el rendimiento. Para una llamada LaunchedEffect { } vacía, se vería de la siguiente manera:
El seguimiento de método anterior se puede separar en tres partes:
- Inicializa una corrutina nueva
- Iniciando corrutina
- Completando la corrutina (porque sale de inmediato)
La cancelación de LaunchedEffect es similar a la finalización normal, excepto que también crea un CancellationException.
En el perfil anterior, un aspecto que resulta sospechoso de inmediato son las llamadas frecuentes a java.util.concurrent.AtomicReferenceFieldUpdater (cuadros de color púrpura o verde con etiquetas j…). Si bien cada llamada es relativamente rápida, la frecuencia es preocupante; cualquier sobrecarga no insignificante que se distribuya en varias invocaciones podría sumarse a una regresión notable. Si se acerca una llamada, se revela que la mayor parte del tiempo se dedica a… ¿comprobaciones de reflexión?
Las corrutinas implementan una estructura de árbol sin bloqueos para las relaciones de superior y secundario que hace posible la simultaneidad estructurada. Resulta que la biblioteca kotlinx.atomicfu implementa operaciones atómicas sin bloqueos con una primitiva de JVM conocida, AtomicReferenceFieldUpdater. El actualizador usa una referencia de clase y un nombre de campo para realizar operaciones atómicas en el entorno de ejecución, y debe ejecutar varias verificaciones de seguridad reflexivas para asegurarse de que el campo exista y sea accesible. Cada operación en las corrutinas (iniciar, suspender, cancelar, completar) llama al menos a una operación atómica, por lo que, si es lenta, las corrutinas no tendrán un buen rendimiento.
Investiga AtomicReferenceFieldUpdater
Pero no nos adelantemos. AtomicReferenceFieldUpdater está bien optimizado en la JVM desde hace más de 10 años, y los seguimientos de métodos pueden capturar la sobrecarga que se quita por completo con una optimización a nivel de la VM: compilaciones Just-In-Time (JIT) o Ahead-Of-Time (AOT). Para verificar el rendimiento, escribamos algunas comparativas para medir la diferencia entre las referencias atómicas de kotlinx.atomicfu y 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 */ }
Si ejecutas esta comparativa en un Pixel 5 (mientras te aseguras de que AtomicReferenceFieldUpdater#compareAndSet se compile con JIT durante el calentamiento), se obtienen los siguientes resultados en un Pixel 5 (API 33):
50.7 ns atomicReference_compareAndSet 135 ns atomicRef_compareAndSet
Las mediciones confirman la brecha, ya que la versión kotlinx.atomicfu es claramente alrededor de 2.7 veces más lenta. Esto confirma que ART no realiza ninguna optimización oculta y que las verificaciones de acceso reflexivo agregan una sobrecarga real durante el tiempo de ejecución.
Si observamos el seguimiento de método original, el único trabajo significativo que realiza AtomicReferenceFieldUpdater es la llamada interna a Unsafe.getObjectVolatile, que ejecuta la operación atómica subyacente. En la mayoría de los casos, el inicializador del actualizador es estático y se puede demostrar que siempre es correcto según la estructura de la clase circundante. Por lo tanto, se podría analizar de forma estática la mayoría de los usos de AtomicReferenceFieldUpdater y reemplazarlos por una variante interna de Unsafe durante la compilación. También sucede que la cadena de herramientas de compilación de Android tiene su propio compilador de optimización que puede hacer exactamente eso.
Optimización con R8
Las clases Atomic*FieldUpdater admiten un uso sutil, dinámico y basado en la reflexión, pero suelen usarse en patrones estáticamente obvios. Esto explica el rendimiento de referencia lento y la necesidad de optimización. R8 es un compilador de optimización de programas completos y es adecuado para ver a través de los patrones más simples para reducir la sobrecarga de las verificaciones de seguridad reflexivas. R8 recibe el código de bytes de JVM después del compilador de Java o Kotlin, pero, para facilitar la legibilidad, estos ejemplos se presentan en la sintaxis de Java. Por este motivo, no hay 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"); // ... } }
En el ejemplo base, se crea un actualizador final estático que accede a un campo volátil con argumentos constantes simples para el contenedor, el tipo y el nombre del campo. La reflexión que se usa es totalmente transparente. Es evidente que este actualizador hace referencia a un campo válido y que el sitio de creación del actualizador tiene acceso válido al campo.
En esencia, Atomic*FieldUpdater es un wrapper alrededor de un desplazamiento de campo y llamadas a Unsafe. En el mejor de los casos, para la optimización, se reemplaza el campo de actualización por un campo de desplazamiento y las llamadas de actualización por llamadas a Unsafe.
Optimización de Atomic*FieldUpdater
La optimización se implementa en tres partes: instrumentación, reemplazo y limpieza.
Instrumentación
El primer paso es introducir campos de desplazamiento junto con el campo de actualización para facilitar el acceso directo a través de la llamada Unsafe .
static final long updater$offset = SyntheticUnsafe.UNSAFE.objectFieldOffset(Example.class.getDeclaredField("data"))
Se accede al campo a través de la reflexión, y se usa Unsafe para extraer el desplazamiento del campo en la clase. Este código representa los elementos internos de Atomic*FieldUpdater si no se tiene en cuenta la validación de reflexión. En su lugar, el tipo de contenedor del actualizador y el tipo de campo del campo volátil se registran de forma estática en el compilador.
Ten en cuenta que el campo original y su inicialización se dejan tal como están. El proceso de optimización facilita y optimiza los usos de forma optimista y, luego, realiza una limpieza. Este es un enfoque simple para la implementación, pero también permite la optimización parcial de los campos de actualización, en los que algunos usos se dejan como estaban, mientras que otros se optimizan.
Reemplazo
En este punto del compilador, después de un punto de unión de simultaneidad adecuado, tenemos una lista de campos de actualización instrumentados. Esto significa que podemos optimizar cada sitio de llamada de forma individual según algunas condiciones. Considera un ejemplo de llamada:
updater.compareAndSet(holder, expectedValue, newValue);
Las condiciones que requiere Atomic*FieldUpdater son las siguientes:
- ¿
updaterproviene de un campo instrumentado? Es decir, ¿puede el análisis estático rastrear el valor del objeto hasta una lectura de campo de un actualizador instrumentado? - ¿
holderes la misma clase o una subclase del tipo de contenedor definido originalmente? - ¿
newValuees la misma clase o una subclase del tipo de campo definido originalmente?
Si se cumplen todas las condiciones, la llamada se reemplaza por una llamada a Unsafe sin ninguna de las verificaciones de reflexión.
SyntheticUnsafe.UNSAFE.compareAndSwapObject(holder, Example.updater$offset, expectedValue, newValue)
Esta nueva llamada es más rápida y sencilla, pero difiere de la original en cuanto al manejo de valores nulos en updater y holder. A menos que se descarten de forma estática, se insertan verificaciones de nulos para ambos.
Corrección
En este punto, la clase contenedora tiene el campo de actualización original y el nuevo campo de desplazamiento, junto con los sitios de llamada que podrían usar cualquiera de los dos. Si no se optimizó ninguno de los sitios de llamada, se debe quitar el campo de desplazamiento, y si se optimizaron todos los sitios de llamada, se debe quitar el campo de actualización. En ambos casos, también se debe borrar la llamada de inicialización. La eliminación de campos sin usar y de código no alcanzado ya se realiza en el compilador, pero quitar el código de inicialización aquí requiere algunos trucos más.
Tanto la llamada a newUpdater como a getDeclaredField pueden tener efectos secundarios, ya que pueden arrojar excepciones (y su implementación también es desconocida, ya que depende de la versión de la API). Esto significa que, por optimización genérica, no se pueden quitar de forma segura. Por lo tanto, esta limpieza requirió una consideración explícita de los campos instrumentados, ya que se sabe de forma estática que no tienen excepciones.
Al final, el ejemplo de actualizador simple que se mostró anteriormente se ve de la siguiente manera después de la optimización:
Resultados
Después de estas optimizaciones, kotlinx.atomicfu y la mayoría de los usos explícitos de AtomicInt/Long/ReferenceFieldUpdater ahora coinciden con el rendimiento de AtomicReference con R8 aplicado. De hecho, es incluso más rápido en algunas comparativas; kotlinx.atomicfu tiene un complemento de compilador que puede intercalar instancias de atomic en campos, lo que reduce las asignaciones necesarias para crear un campo actualizado de forma atómica.
Jetpack Compose fue el principal beneficiario de este trabajo. El entorno de ejecución de Compose tiene una serie de microcomparativas que realizan un seguimiento muy de cerca del rendimiento de las corrutinas para detectar regresiones de rendimiento de forma temprana. Cuando se actualizaron las comparativas a una nueva versión de R8, notamos una mejora del doble cuando se iniciaban y cancelaban corrutinas en LaunchedEffect.
Aparte de eso, el equipo de ART está implementando estas optimizaciones de forma nativa a nivel de la VM. Si tu app se orienta a la API 36 y se ejecuta en una versión reciente de Android, es posible que tu dispositivo ya esté optimizando las corrutinas de una manera similar. Las comparativas de corrutinas anteriores observaron una mejora de ~15% en el rendimiento después de las actualizaciones de JIT en las versiones recientes de ART.
Tu app recibirá esta optimización de forma predeterminada cuando actualices a AGP 9.2.0 o uses R8 9.2.0 directamente. Para obtener más información, consulta D8 dexer and R8 shrinker.
-
Casos de éxitoGrab es la superapp líder del sudeste asiático, que ofrece entregas, movilidad y servicios financieros a millones de usuarios en ocho países a través de una sola app.
Ajesh Pai, Nguyen Van Minh, Tracy Agyemang • Lectura de 5 min -
Casos de éxitoEl equipo creó una base de código de IU nativa de IA que es un 50% más pequeña que la implementación original, a la vez que logró una reducción del 35% en el tiempo de ejecución del agente de IA, un 32% menos de intercambios entre ingenieros y agentes, y una reducción del 33% en el costo de los tokens.
Pavlo Stavytskyi, Rebecca Franks • Lectura de 11 min -
Casos de éxitoWhatsApp es la plataforma de mensajería más grande del mundo, con miles de millones de usuarios en todo el planeta. Es la herramienta de comunicación predeterminada para personas de diversas regiones, ya que conecta a los usuarios a través de mensajes privados, confiables y seguros.
Niharika Arora, Tracy Agyemang, Mayank Jain • Lectura de 8 min
Recibe la información más reciente sobre el desarrollo de Android en tu bandeja de entrada todas las semanas.