Studi Kasus

Cara R8 membuat Coroutine Kotlin di Android 2x lebih cepat

7 menit membaca

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 menjadi 2x lebih cepat. Untuk mendapatkan manfaatnya, update AGP Anda ke 9.2.0 atau yang lebih baru.

Dengan sebagian besar aplikasi Android yang mengadopsi Kotlin sebagai bahasa utama pilihan mereka, kotlinx.coroutines telah menjadi standar de-facto untuk pemrograman asinkron. Library ini menawarkan cara yang dirancang dengan baik dan terstruktur untuk mengelola aliran serentak yang bersifat native ke Kotlin. Jetpack Compose tidak terkecuali, mengadopsi coroutine untuk mengelola peristiwa pointer, 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 bottleneck untuk banyak operasi yang terjadi di luar komposisi. Sebagai contoh, 80% waktu yang dihabiskan untuk membuat dan mengupdate Modifier.clickable digunakan untuk meluncurkan dan membatalkan coroutine internal yang menangani update InteractionSource. Berdasarkan pengamatan tersebut, sebagian besar pekerjaan performa awal berfokus pada penghapusan coroutine dari jalur default dan menunda inisialisasi hingga diperlukan. 

Biaya coroutine

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

pic01_enhanced.png
Pelacakan metode LaunchedEffect yang divisualisasikan di UI Perfetto

Pelacakan metode di atas dapat dipisahkan menjadi tiga bagian:

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

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

Dari profil di atas, satu hal yang langsung mencurigakan adalah panggilan yang sering ke java.util.concurrent.AtomicReferenceFieldUpdater (kotak ungu atau hijau dengan label j…). Meskipun setiap panggilan relatif cepat, frekuensinya mengkhawatirkan; overhead yang tidak dapat diabaikan yang tersebar di beberapa pemanggilan mungkin akan bertambah hingga menjadi regresi yang terlihat. Jika panggilan diperbesar, sebagian besar waktu dihabiskan untuk... pemeriksaan refleksi?

pic02-enhanced.png
Tampilan close-up pelacakan metode AtomicReferenceFieldUpdater.get selama inisialisasi LaunchedEffect

Coroutine mengimplementasikan struktur pohon tanpa kunci untuk hubungan induk-turunan yang memungkinkan konkurensi terstruktur. Ternyata, library kotlinx.atomicfu mengimplementasikan operasi atomik tanpa kunci menggunakan primitif JVM yang dikenal baik, 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 berperforma baik.

Menyelidiki AtomicReferenceFieldUpdater

Namun, jangan terburu-buru. AtomicReferenceFieldUpdater sebenarnya dioptimalkan dengan baik di JVM selama lebih dari 10 tahun sekarang, dan rekaman aktivitas metode mungkin menangkap overhead yang sepenuhnya dihapus oleh pengoptimalan tingkat VM: kompilasi tepat waktu (JIT) atau kompilasi di awal (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 (sambil 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 ini mengonfirmasi kesenjangan, dengan versi kotlinx.atomicfu yang jelas sekitar 2,7x lebih lambat. Hal ini mengonfirmasi bahwa ART tidak melakukan pengoptimalan tersembunyi dan pemeriksaan akses reflektif menambahkan overhead nyata selama runtime.

Jika melihat kembali pelacakan metode asli, satu-satunya pekerjaan yang bermakna 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, seseorang dapat menganalisis secara statis sebagian besar penggunaan AtomicReferenceFieldUpdater 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 pengoptimalan. R8 adalah compiler pengoptimalan program lengkap dan sangat cocok untuk melihat pola yang lebih sederhana guna mengurangi overhead pemeriksaan keamanan reflektif. R8 menerima bytecode JVM setelah compiler Java atau Kotlin, tetapi untuk memudahkan 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 final statis yang mengakses kolom volatil dengan argumen konstanta sederhana untuk penampung, jenis, dan nama kolom. Refleksi yang digunakan benar-benar transparan. Jelas terlihat bahwa updater ini mereferensikan kolom yang valid dan bahwa 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 diimplementasikan 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 mewakili internal Atomic*FieldUpdater jika Anda mengabaikan validasi refleksi. Sebagai gantinya, jenis penampung updater dan jenis kolom kolom volatil dilacak secara statis di compiler.

 

Perhatikan bahwa kolom asli dan inisialisasinya dibiarkan apa adanya. Proses pengoptimalan secara optimis memfasilitasi dan mengoptimalkan penggunaan, lalu membersihkannya nanti. Ini adalah pendekatan sederhana untuk implementasi, 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 diinstrumentasi. Artinya, kita dapat mengoptimalkan setiap situs panggilan secara individual 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 diinstrumentasi?
  • Apakah holder adalah class yang sama atau subclass dari jenis penampung yang ditentukan awalnya?
  • Apakah newValue adalah class yang sama atau subclass dari jenis kolom yang ditentukan awalnya?

Jika semua kondisi terpenuhi, panggilan akan diganti dengan panggilan ke Unsafe tanpa pemeriksaan refleksi.

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

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

Pembersihan

Pada titik 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 yang tidak digunakan sudah dilakukan di compiler, tetapi menghapus kode inisialisasi di sini memerlukan beberapa trik lagi.

Panggilan ke newUpdater dan getDeclaredField mungkin memiliki efek samping karena dapat menampilkan pengecualian (dan implementasinya juga tidak diketahui karena bergantung pada versi API). Artinya, dengan pengoptimalan generik, keduanya tidak dapat dihapus dengan aman. Jadi, pembersihan ini memerlukan pertimbangan eksplisit 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 eksplisit AtomicInt/Long/ReferenceFieldUpdater kini cocok dengan performa AtomicReference dengan R8 yang diterapkan. Bahkan, beberapa tolok ukur menunjukkan bahwa kotlinx.atomicfu lebih cepat; plugin compiler yang dapat meng-inline 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 tolok ukur mikro yang melacak performa coroutine dengan sangat cermat untuk menangkap regresi performa sejak awal. Saat tolok ukur diupdate ke versi R8 baru, kami melihat peningkatan 2x saat meluncurkan dan membatalkan coroutine di LaunchedEffect!

pic03_enhanced.png
Diagram tolok ukur yang menggambarkan waktu yang diperlukan saat memulai dan membatalkan coroutine di LaunchedEffect (lebih rendah lebih baik). Perubahan dalam diagram sesuai dengan update R8, yang menunjukkan peningkatan 2x.

Selain itu, tim ART mengimplementasikan pengoptimalan ini secara native di tingkat VM. Jika aplikasi Anda menargetkan API 36 dan berjalan di versi Android terbaru, ada kemungkinan perangkat Anda sudah mengoptimalkan coroutine dengan cara yang serupa. Tolok ukur coroutine di atas mengamati peningkatan performa ~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