Casos de éxito

Cómo R8 hizo que las corrutinas de Kotlin en Android fueran 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 de Unsafe que tienen un rendimiento de 2 a 4 veces mejor en operaciones comunes. Esto tiene un impacto particularmente grande en la biblioteca kotlinx.atomicfu que implementa atómicos 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 adoptaron 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 en Compose llaman a funciones suspend de forma interna 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 iniciar y cancelar 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 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 registro de seguimiento del método de Android Runtime (ART). Un registro de seguimiento de métodos de ART es una herramienta que registra el flujo de ejecución de una app y 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 a LaunchedEffect { } vacía, se vería de la siguiente manera:

pic01_enhanced.png
Seguimiento de método de LaunchedEffect visualizado en la IU de Perfetto

El seguimiento de método anterior se puede separar en tres partes:

  • Cómo inicializar una corrutina nueva
  • Cómo iniciar una corrutina
  • Completar la corrutina (porque sale de inmediato)

La cancelación LaunchedEffect es similar a la finalización normal, excepto que también crea un CancellationException.

En el perfil anterior, algo que resulta sospechoso de inmediato son las llamadas frecuentes a java.util.concurrent.AtomicReferenceFieldUpdater (cajas moradas o verdes 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 acumularse y generar una regresión notable. Si acercamos la imagen de una llamada, se revela que la mayor parte del tiempo se dedica a… ¿comprobaciones de reflejo?

pic02-enhanced.png
Una mirada de cerca al seguimiento de método de AtomicReferenceFieldUpdater.get durante la inicialización de LaunchedEffect

Las corrutinas implementan una estructura de árbol sin bloqueo para las relaciones principal-secundario 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 las 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 tendrán un buen rendimiento.

Investigación de AtomicReferenceFieldUpdater

Pero no nos adelantemos. AtomicReferenceFieldUpdater está bien optimizado en la JVM desde hace más de 10 años, y los registros de seguimiento de métodos pueden capturar una 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.atomicfujava.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 (y te aseguras de que AtomicReferenceFieldUpdater#compareAndSet se compile con JIT durante el calentamiento), obtendrás los siguientes resultados en el 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 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 a menudo se usan en patrones estáticamente obvios. Esto explica el rendimiento lento del modelo de referencia y la necesidad de optimización. R8 es un compilador de optimización de programas completos y es adecuado para ver los patrones más simples y 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 lectura, 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");
        // ...
    }
}

El ejemplo base 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 que se usa es totalmente transparente. Se puede ver claramente 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 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.

Cómo optimizar 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 la compensación del campo en la clase. Este código representa el funcionamiento interno de Atomic*FieldUpdater si no se tiene 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 rastrean 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 una 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 y 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:

  • ¿updater proviene de un campo instrumentado? Es decir, ¿el análisis estático puede hacer un seguimiento del valor del objeto hasta una lectura de campo de un actualizador instrumentado?
  • ¿holder es la misma clase o una subclase del tipo de titular definido originalmente?
  • ¿newValue es 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 updaterholder. A menos que se descarten de forma estática, se insertan verificaciones de nulos para ambos. 

Corrección

En este punto, la clase de retención tiene el campo de actualización original y el nuevo campo de desplazamiento, junto con 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. 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. El compilador ya se encarga de borrar los campos sin usar y quitar el código no alcanzado, 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, con 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 contienen 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 del compilador que puede insertar 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 tiempo de ejecución de Compose tiene varias comparativas de microbenchmarks que registran el rendimiento de las corrutinas muy de cerca para detectar regresiones de rendimiento de forma anticipada. Cuando se actualizaron las comparativas a una nueva versión de R8, notamos una mejora del doble al iniciar y cancelar corrutinas en LaunchedEffect.

pic03_enhanced.png
Gráfico de comparativas que ilustra el tiempo que se tarda en iniciar y cancelar corrutinas en LaunchedEffect (los niveles más bajos indican mayor rendimiento). El cambio en el gráfico corresponde a una actualización de R8, que muestra una mejora del doble.

Además de eso, el equipo de ART está implementando estas optimizaciones de forma nativa a nivel de la VM. Si tu app se segmenta para 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 del rendimiento de aproximadamente el 15% 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.

Escrito por:
Continuar leyendo