Studi Kasus

Cara R8 membuat Coroutine Kotlin di Android 2x lebih cepat

Waktu baca: 7 menit

Mulai AGP 9.2.0, R8 mengoptimalkan sebagian besar panggilan Atomic*FieldUpdater menjadi varian Unsafe yang berperforma 2x hingga 4x lebih baik pada operasi umum. Hal ini memiliki dampak yang sangat besar pada library kotlinx.atomicfu yang mengimplementasikan atomik untuk kotlinx.coroutines, sehingga peluncuran dan pembatalan coroutine hingga 2x lebih cepat. Untuk mendapatkan manfaatnya, update AGP Anda ke 9.2.0 atau yang lebih tinggi.

Dengan sebagian besar aplikasi Android mengadopsi Kotlin sebagai bahasa pilihan utama mereka, kotlinx.coroutines telah menjadi standar de facto untuk pemrograman asinkron. Library ini menawarkan cara yang dirancang dengan baik dan terstruktur untuk mengelola flow serentak yang bersifat native ke Kotlin. Jetpack Compose tidak terkecuali, mengadopsi coroutine untuk mengelola peristiwa penunjuk, animasi, dan interaksi lainnya. Pada saat penulisan, sebagian besar API serentak di Compose memanggil fungsi suspend di balik layar dan meluncurkan dan/atau membatalkan coroutine untuk menangani update.

Saat tim Compose mulai menyelidiki performa, coroutine ditemukan sebagai hambatan untuk banyak operasi yang terjadi di luar komposisi. Sebagai contoh, 80% waktu yang dihabiskan untuk membuat dan memperbarui Modifier.clickable digunakan untuk meluncurkan dan membatalkan coroutine internal yang menangani pembaruan InteractionSource. Berdasarkan pengamatan tersebut, sebagian besar pekerjaan performa awal berfokus pada penghapusan coroutine dari jalur default dan penundaan inisialisasi hingga diperlukan. 

Biaya coroutine

Cara termudah untuk menganalisis perilaku internal fungsi di Android adalah dengan merekam pelacakan metode Android Runtime (ART). Pelacakan metode ART adalah alat yang merekam alur eksekusi aplikasi, yang menunjukkan dengan tepat metode mana yang dipanggil, urutannya, dan berapa banyak waktu yang dihabiskan di setiap metode, sehingga memungkinkan developer mengidentifikasi bottleneck performa. Untuk panggilan LaunchedEffect { } kosong, tampilannya akan seperti ini:

pic01_enhanced.png
Pelacakan metode LaunchedEffect yang divisualisasikan di UI Perfetto

Pelacakan metode di atas dapat dibagi menjadi tiga bagian:

  • Menginisialisasi coroutine baru
  • Memulai coroutine
  • Menyelesaikan coroutine (karena segera keluar)

Membatalkan LaunchedEffect mirip dengan penyelesaian normal, kecuali juga membuat CancellationException.

Dari profil di atas, salah satu hal yang langsung mencurigakan adalah seringnya panggilan ke java.util.concurrent.AtomicReferenceFieldUpdater (kotak ungu atau hijau dengan label j…). Meskipun setiap panggilan relatif cepat, frekuensinya mengkhawatirkan; setiap overhead yang tidak dapat diabaikan yang tersebar di beberapa pemanggilan dapat menyebabkan regresi yang terlihat jelas. Memperbesar panggilan akan mengungkapkan bahwa sebagian besar waktu dihabiskan untuk... pemeriksaan pantulan?

pic02-enhanced.png
Melihat dari dekat pelacakan metode AtomicReferenceFieldUpdater.get selama inisialisasi LaunchedEffect

Coroutine menerapkan struktur pohon bebas kunci untuk hubungan induk-turunan yang memungkinkan konkurensi terstruktur. Ternyata, library kotlinx.atomicfu mengimplementasikan operasi atomik bebas kunci menggunakan primitif JVM yang sudah dikenal, AtomicReferenceFieldUpdater. Updater menggunakan referensi class dan nama kolom untuk melakukan operasi atomik saat runtime, dan harus menjalankan beberapa pemeriksaan keamanan reflektif untuk memastikan kolom ada dan dapat diakses. Setiap operasi dalam coroutine (memulai, menangguhkan, membatalkan, menyelesaikan) memanggil setidaknya satu operasi atomik, jadi jika lambat, coroutine tidak akan berfungsi dengan baik.

Menyelidiki AtomicReferenceFieldUpdater

Namun, jangan terlalu terburu-buru. AtomicReferenceFieldUpdater sebenarnya dioptimalkan dengan baik di JVM selama lebih dari 10 tahun, dan rekaman aktivitas metode dapat merekam overhead yang sepenuhnya dihapus oleh pengoptimalan tingkat VM: kompilasi tepat waktu (JIT) atau ahead-of-time (AOT). Untuk memverifikasi performa, mari kita tulis beberapa tolok ukur untuk mengukur perbedaan antara referensi atomik dari kotlinx.atomicfu dan 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 */
}

Menjalankan tolok ukur ini di Pixel 5 (sekaligus memastikan AtomicReferenceFieldUpdater#compareAndSet dikompilasi JIT selama pemanasan), akan menghasilkan hasil berikut di Pixel 5 (API 33):

 50.7 ns  atomicReference_compareAndSet
135   ns  atomicRef_compareAndSet

Pengukuran mengonfirmasi kesenjangan tersebut, dengan versi kotlinx.atomicfu jelas sekitar 2,7x lebih lambat. Hal ini mengonfirmasi bahwa ART tidak melakukan pengoptimalan tersembunyi dan pemeriksaan akses reflektif menambahkan overhead nyata selama runtime.

Melihat kembali rekaman aktivitas metode asli, satu-satunya tugas penting yang dilakukan oleh AtomicReferenceFieldUpdater adalah panggilan internal ke Unsafe.getObjectVolatile yang sebenarnya menjalankan operasi atomik yang mendasarinya. Dalam sebagian besar kasus, penginisialisasi updater bersifat statis, dan dapat terbukti selalu benar berdasarkan struktur class di sekitarnya. Dengan demikian, orang dapat menganalisis sebagian besar penggunaan AtomicReferenceFieldUpdater secara statis dan menggantinya dengan varian Unsafe internal selama kompilasi. Selain itu, toolchain build Android memiliki compiler pengoptimalan sendiri yang dapat melakukan hal tersebut.

Pengoptimalan dengan R8

Class Atomic*FieldUpdater mendukung penggunaan yang halus, dinamis, dan berbasis refleksi, tetapi sering digunakan dalam pola yang jelas secara statis. Hal ini menjelaskan performa dasar yang lambat dan keinginan untuk melakukan pengoptimalan. R8 adalah compiler pengoptimalan program lengkap dan cocok untuk melihat pola yang lebih sederhana guna mengurangi overhead pemeriksaan keamanan reflektif. R8 menerima bytecode JVM setelah compiler Java atau Kotlin, tetapi untuk mempermudah keterbacaan, contoh ini disajikan dalam sintaksis Java. Itulah sebabnya tidak ada argumen jenis untuk AtomicReferenceFieldUpdater.

class Example {
    volatile String data = "";
    static final AtomicReferenceFieldUpdater updater =
        AtomicReferenceFieldUpdater.newUpdater(Example.class, String.class, "data");

    void example() {
        // ...
        updater.compareAndSet(this, "", "new");
        // ...
    }
}

Contoh dasar membuat updater akhir statis yang mengakses kolom yang tidak stabil dengan argumen konstanta sederhana untuk pemegang, jenis, dan nama kolom. Pantulan yang digunakan benar-benar transparan. Updater ini jelas merujuk ke kolom yang valid dan situs pembuatan updater memiliki akses yang valid ke kolom tersebut.

Pada dasarnya, Atomic*FieldUpdater adalah wrapper di sekitar offset kolom dan panggilan ke Unsafe. Skenario terbaik untuk pengoptimalan adalah mengganti kolom updater dengan kolom offset dan mengganti panggilan updater dengan panggilan ke Unsafe.

Mengoptimalkan Atomic*FieldUpdater

Pengoptimalan diterapkan dalam tiga bagian: Instrumentasi, Penggantian, dan Pembersihan. 

Instrumentasi

Langkah pertama adalah memperkenalkan kolom offset bersama kolom updater untuk memfasilitasi akses langsung melalui panggilan Unsafe .

static final long updater$offset =
    SyntheticUnsafe.UNSAFE.objectFieldOffset(Example.class.getDeclaredField("data"))

Kolom diakses melalui refleksi, dan Unsafe digunakan untuk mengekstrak offset kolom pada class. Kode ini merepresentasikan internal Atomic*FieldUpdater jika Anda mengabaikan validasi refleksi. Sebagai gantinya, jenis pemegang updater dan jenis kolom kolom yang mudah berubah dilacak secara statis di compiler.

 

Perhatikan bahwa kolom asli dan inisialisasinya dibiarkan apa adanya. Proses pengoptimalan secara optimis memfasilitasi dan mengoptimalkan penggunaan, lalu membersihkan. Ini adalah pendekatan sederhana untuk penerapan, tetapi juga memungkinkan pengoptimalan sebagian kolom updater, dengan beberapa penggunaan dibiarkan seperti semula sementara yang lain dioptimalkan.

Penggantian

Pada titik ini di compiler, setelah titik gabungan konkurensi yang sesuai, kita memiliki daftar kolom updater yang diinstrumentasikan. Artinya, kita dapat mengoptimalkan setiap situs panggilan satu per satu berdasarkan beberapa kondisi. Pertimbangkan contoh panggilan:

updater.compareAndSet(holder, expectedValue, newValue);

Kondisi yang diperlukan Atomic*FieldUpdater adalah sebagai berikut:

  • Apakah updater berasal dari kolom yang diinstrumentasi? Artinya, dapatkah analisis statis melacak nilai objek kembali ke pembacaan kolom updater yang diinstrumentasikan?
  • Apakah holder merupakan class yang sama atau subclass dari jenis penampung yang ditentukan sebelumnya?
  • Apakah newValue adalah class yang sama atau subclass dari jenis kolom yang ditentukan semula?

Jika semua kondisi terpenuhi, panggilan akan digantikan dengan panggilan ke Unsafe tanpa pemeriksaan refleksi apa pun.

SyntheticUnsafe.UNSAFE.compareAndSwapObject(holder, Example.updater$offset, expectedValue, newValue)

Panggilan baru ini lebih cepat dan sederhana, tetapi berbeda dari panggilan asli dalam hal penanganan nilai null di updater dan holder. Kecuali jika secara statis dikesampingkan, pemeriksaan null akan dimasukkan untuk keduanya. 

Pembersihan

Pada tahap ini, class penampung memiliki kolom updater asli dan kolom offset baru beserta situs panggilan yang mungkin menggunakan salah satu dari keduanya. Jika tidak ada situs panggilan yang dioptimalkan, kolom offset harus dihapus dan jika semua situs panggilan dioptimalkan, kolom updater harus dihapus. Dalam kedua kasus tersebut, panggilan inisialisasi juga harus dihapus. Penghapusan kolom yang tidak digunakan dan penghapusan kode mati sudah dilakukan di compiler, tetapi penghapusan kode inisialisasi di sini memerlukan beberapa trik lagi.

Panggilan ke newUpdater dan getDeclaredField mungkin memiliki efek samping karena dapat memunculkan pengecualian (dan implementasinya juga tidak diketahui karena bergantung pada versi API). Artinya, dengan pengoptimalan generik, mereka tidak dapat dihapus dengan aman. Jadi, pembersihan ini memerlukan pertimbangan eksplisit terhadap kolom yang diinstrumentasi, karena kolom tersebut secara statis diketahui bebas dari pengecualian.

Pada akhirnya, contoh updater sederhana yang ditampilkan di atas akan terlihat seperti ini setelah pengoptimalan:

Hasil

Setelah pengoptimalan ini, kotlinx.atomicfu dan sebagian besar penggunaan AtomicInt/Long/ReferenceFieldUpdater yang eksplisit kini cocok dengan performa AtomicReference dengan R8 yang diterapkan. Faktanya, plugin ini bahkan lebih cepat dalam beberapa tolok ukur; kotlinx.atomicfu memiliki plugin compiler yang dapat menyisipkan instance atomic ke dalam kolom, sehingga mengurangi alokasi yang diperlukan untuk membuat kolom yang diupdate secara atomik.

Jetpack Compose adalah penerima manfaat utama dari pekerjaan ini. Runtime Compose memiliki sejumlah microbenchmark yang melacak performa coroutine dengan sangat cermat untuk mendeteksi regresi performa sejak awal. Saat tolok ukur diupdate ke R8 versi baru, kami melihat peningkatan 2x saat meluncurkan dan membatalkan coroutine di LaunchedEffect.

pic03_enhanced.png
Grafik tolok ukur yang mengilustrasikan waktu yang diperlukan saat memulai dan membatalkan coroutine di LaunchedEffect (lebih rendah lebih baik). Perubahan pada grafik sesuai dengan update R8, yang menunjukkan peningkatan 2x.

Selain itu, tim ART menerapkan pengoptimalan ini secara native di tingkat VM. Jika aplikasi Anda menargetkan API 36 dan berjalan di Android versi terbaru, kemungkinan perangkat Anda sudah mengoptimalkan coroutine dengan cara yang serupa. Benchmark coroutine di atas mengamati peningkatan performa sekitar 15% setelah update JIT di versi ART terbaru.

Aplikasi Anda akan menerima pengoptimalan ini secara default saat mengupgrade ke AGP 9.2.0 atau dengan menggunakan R8 9.2.0 secara langsung. Untuk mengetahui informasi selengkapnya, lihat D8 dexer dan R8 shrinker.

Ditulis oleh:
Lanjutkan membaca