Cómo R8 hizo que las corrutinas de Kotlin en Android sean 2 veces más 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 no seguras que funcionan de 2 a 4 veces mejor en 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 lanzamiento y la cancelación de corrutinas sean hasta 2 veces más rápidos. Para obtener los beneficios, actualiza tu AGP a 9.2.0 o una versión 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. Al momento de escribir este artículo, la mayoría de las APIs simultáneas en Compose llaman a funciones suspend en segundo plano y lanzan 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 el lanzamiento y la cancelación de corrutinas internas que controlaban las actualizaciones de InteractionSource. Según esas observaciones, gran parte del trabajo inicial de rendimiento se centró en quitar las corrutinas de la ruta de acceso predeterminada y retrasar la inicialización hasta que fuera necesario.
El costo de una corrutina
La forma más fácil 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, que muestra exactamente qué métodos se llaman, su orden y cuánto tiempo se dedica a cada uno, lo que permite a los desarrolladores identificar 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:
- Inicialización de una corrutina nueva
- Inicio de la corrutina
- Finalización de la corrutina (porque se cierra de inmediato)
La cancelación de LaunchedEffect es similar a la finalización normal, excepto que también crea una CancellationException.
En el perfil anterior, una de las cosas que es inmediatamente sospechosa son las llamadas frecuentes a java.util.concurrent.AtomicReferenceFieldUpdater (cuadros morados o verdes con etiquetas j…). Si bien cada llamada es relativamente rápida, la frecuencia es preocupante; cualquier sobrecarga no despreciable que se extienda a través de varias invocaciones podría sumar una regresión notable. Si acercas una llamada, ¿se revela que la mayor parte del tiempo se dedica a las verificaciones de reflexión?
Las corrutinas implementan una estructura de árbol sin bloqueo para las relaciones jerárquicas que hace posible la simultaneidad estructurada. Resulta que la biblioteca kotlinx.atomicfu implementa operaciones atómicas sin bloqueo 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 tiempo 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 corrutinas (inicio, suspensión, cancelación, finalización) llama al menos a una operación atómica, por lo que, si es lenta, las corrutinas no funcionarán bien.
Investigación de AtomicReferenceFieldUpdater
Pero no nos adelantemos.AtomicReferenceFieldUpdater está bien optimizado en JVM desde hace más de 10 años, y los seguimientos de método pueden capturar una sobrecarga que se quita por completo con una optimización a nivel de VM: compilaciones just-in-time (JIT) o ahead-of-time (AOT). Para verificar el rendimiento, escribamos algunos parámetros de comparativa 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 este parámetro de 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 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 aproximadamente 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 volvemos al 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 Unsafe interna 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 Atomic*FieldUpdater clases admiten un uso sutil, dinámico y basado en la reflexión, pero a menudo se usan en patrones estáticamente obvios. Esto explica el rendimiento lento de la línea de base y el deseo de optimización. R8 es un compilador de optimización de programa completo y es adecuado para ver los patrones más simples para omitir la sobrecarga de las verificaciones de seguridad reflexivas. R8 recibe código de bytes de JVM después del compilador de Java o Kotlin, pero, para facilitar la legibilidad, estos ejemplos se presentan en sintaxis de Java. Por eso 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 titular, el tipo y el nombre del campo. La reflexión utilizada es totalmente transparente. Es claro ver 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. El mejor caso de uso para la optimización es reemplazar el campo del actualizador por un campo de desplazamiento y reemplazar las llamadas del actualizador por llamadas a Unsafe.
Optimización de Atomic*FieldUpdater
La optimización se implementa en tres partes: instrumentación, reemplazo y corrección.
Instrumentación
El primer paso es introducir campos de desplazamiento junto con el campo del actualizador 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 tienes en cuenta la validación de la reflexión. En cambio, el tipo de titular 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 de forma optimista los usos y, luego, realiza la limpieza. Este es un enfoque simple para la implementación, pero también permite la optimización parcial de los campos del actualizador, en la 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 del actualizador instrumentados. Esto significa que podemos optimizar cada sitio de llamada de forma individual según algunas condiciones. Considera una llamada de ejemplo:
updater.compareAndSet(holder, expectedValue, newValue);
Las condiciones que requiere Atomic*FieldUpdater son las siguientes:
- ¿
updaterproviene de un campo instrumentado? Es decir, ¿el análisis estático puede rastrear el valor del objeto hasta una lectura de campo de un actualizador instrumentado? - ¿
holderes la misma clase o una subclase del tipo de titular 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 llamada original en cuanto al manejo de valores nulos en updater y holder. A menos que se descarte de forma estática, se insertan verificaciones de valores nulos para ambos.
Corrección
En este punto, la clase de retención tiene el campo del actualizador 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 del actualizador. En ambos casos, también se debe borrar la llamada de inicialización. El borrado de campos no utilizados y la eliminación de código no alcanzado ya se realizan 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, mediante la 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 simple del actualizador que se muestra arriba 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 aún más rápido en algunos parámetros de comparativa; kotlinx.atomicfu tiene un complemento del compilador que puede insertar instancias 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 tiempo de ejecución de Compose tiene varios microparámetros de comparativa que registran el rendimiento de la corrutina muy de cerca para detectar regresiones de rendimiento de forma temprana. Cuando se actualizaron los parámetros de comparativa a una versión nueva de R8, notamos una mejora de 2 veces cuando se lanzan y cancelan corrutinas en LaunchedEffect!
Además, el equipo de ART implementa estas optimizaciones de forma nativa a nivel de VM. Si tu app está orientada 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. Los parámetros de comparativa de corrutinas anteriores observaron una mejora de aproximadamente el 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 y R8 shrinker.
-
Casos de éxitoLas regresiones de rendimiento son notoriamente difíciles de reproducir, lo que las convierte en un cuello de botella masivo para los desarrolladores de dispositivos móviles.
Alice Yuan, Arti Arutiunov, Nikita Ogorodnikov • Lectura de 4 min -
Casos de éxitoRecientemente, FotMob experimentó su mayor aumento de un solo día en Wear OS entre su público instalado en 5 años, con un promedio diario de 2 a 3 veces. ¿El secreto? Un flujo de instalación multidispositivo simple que ayuda a los usuarios a descubrir su app para Wear OS directamente desde su teléfono.
Garan Jenkin • Lectura de 3 min -
Casos de éxitoLa app de mindfulness Gratitude fomenta la coherencia a través de un diario diario, afirmaciones y tableros de visión. La app tiene más de 6 millones de descargas, 150 mil calificaciones de 5 estrellas y 100 millones de entradas de diario registradas.
Amrit Sanjeev, Ash Nohe • Lectura de 3 min
Recibe la información más reciente sobre el desarrollo de Android en tu bandeja de entrada todas las semanas.