Başarılı Örnekler

R8, Android'de Kotlin eş yordamlarını nasıl 2 kat daha hızlı hale getirdi?

Okuma süresi: 7 dk.

AGP 9.2.0'dan itibaren R8, çoğu Atomic*FieldUpdater çağrısını, yaygın işlemlerde 2 ila 4 kat daha iyi performans gösteren Unsafe varyantlarına optimize eder. Bu durum, kotlinx.coroutines için atomikleri uygulayan kotlinx.atomicfu kitaplığı üzerinde özellikle büyük bir etkiye sahiptir ve eş yordamların başlatılmasını ve iptal edilmesini 2 kata kadar hızlandırır. Avantajlardan yararlanmak için AGP'nizi 9.2.0 veya sonraki bir sürüme güncelleyin.

Android uygulamalarının çoğunda ana dil olarak Kotlin'in tercih edilmesiyle kotlinx.coroutines, eşzamansız programlama için fiili bir standart haline geldi. Kitaplık, Kotlin'e özgü eşzamanlı akışları yönetmek için iyi tasarlanmış ve yapılandırılmış bir yöntem sunar. Jetpack Compose da bu konuda bir istisna değildi ve işaretçi etkinliklerini, animasyonları ve diğer etkileşimleri yönetmek için eş yordamları benimsedi. Bu makalenin yazıldığı sırada, Compose'daki çoğu eşzamanlı API, arka planda suspend işlevlerini çağırır ve güncellemeleri işlemek için eş yordamları başlatır ve/veya iptal eder.

Compose ekibi performansı incelemeye başladığında, eş yordamların oluşturma dışında gerçekleşen birçok işlem için darboğaz oluşturduğu keşfedildi. Örneğin, Modifier.clickable oluşturmak ve güncellemek için harcanan zamanın% 80'i, InteractionSource güncellemelerini işleyen dahili eş yordamları başlatmak ve iptal etmek için harcanıyordu. Bu gözlemlere dayanarak, ilk performans çalışmalarının çoğu, eş yordamları varsayılan yoldan kaldırmaya ve başlatmayı gerektiği zamana kadar geciktirmeye odaklandı. 

Eş yordamın maliyeti

Android'de bir işlevin dahili davranışını analiz etmenin en kolay yolu, Android Çalışma Zamanı (ART) yöntem izini yakalamaktır. ART yöntem izi, bir uygulamanın yürütme akışını kaydeden bir araçtır. Tam olarak hangi yöntemlerin çağrıldığını, sıralarını ve her birinde ne kadar zaman harcandığını göstererek geliştiricilerin performans darboğazlarını belirlemesine olanak tanır. Boş bir LaunchedEffect { } çağrısı için şuna benzer bir şey görürsünüz:

pic01_enhanced.png
Perfetto kullanıcı arayüzünde görselleştirilen LaunchedEffect yöntem izi

Yukarıdaki yöntem izi üç bölüme ayrılabilir:

  • Yeni bir eş yordam başlatılıyor
  • Eş yordam başlatılıyor
  • Eş yordam tamamlanıyor (hemen çıkış yaptığı için)

LaunchedEffect iptali, normal tamamlamaya benzer ancak bir CancellationException da oluşturur.

Yukarıdaki profilde hemen şüphe uyandıran bir şey, java.util.concurrent.AtomicReferenceFieldUpdater (j… etiketli mor veya yeşil kutular) sık sık yapılan aramalardır. Her çağrı nispeten hızlı olsa da sıklık endişe vericidir. Birden fazla çağrıya yayılan ve ihmal edilemeyecek düzeydeki ek yük, fark edilir bir gerilemeye yol açabilir. Görüşmeye yakından baktığımızda, zamanın çoğunun yansıma kontrollerine harcandığını görüyoruz.

pic02-enhanced.png
LaunchedEffect başlatma sırasında AtomicReferenceFieldUpdater.get yöntem izine yakından bakış

Eş yordamlar, yapılandırılmış eşzamanlılığı mümkün kılan üst-alt ilişkileri için kilitsiz bir ağaç yapısı uygular. kotlinx.atomicfu kitaplığının, iyi bilinen bir JVM temel öğesi olan AtomicReferenceFieldUpdater'ı kullanarak kilitsiz atomik işlemler uyguladığı ortaya çıktı. Güncelleyici, çalışma zamanında atomik işlemler gerçekleştirmek için bir sınıf referansı ve alan adı kullanır. Ayrıca, alanın mevcut ve erişilebilir olduğundan emin olmak için birkaç yansıtıcı güvenlik kontrolü yapması gerekir. Eş yordamlardaki her işlem (başlatma, askıya alma, iptal etme, tamamlama) en az bir atomik işlem çağırır. Bu nedenle, yavaşsa eş yordamlar iyi performans göstermez.

AtomicReferenceFieldUpdater'ı inceleme

Ancak acele etmeyelim. AtomicReferenceFieldUpdater, JVM'de 10 yılı aşkın süredir iyi bir şekilde optimize edilmiştir. Yöntem izleri, sanal makine düzeyinde optimizasyonla tamamen kaldırılan ek yükü yakalayabilir: tam zamanında (JIT) veya önceden (AOT) derlemeler. Performansı doğrulamak için kotlinx.atomicfu ve java.util.concurrent.atomic paketlerindeki atomik referanslar arasındaki farkı ölçen birkaç karşılaştırma testi yazalım.

@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 */
}

Bu karşılaştırmayı Pixel 5'te çalıştırdığınızda (ısınma sırasında AtomicReferenceFieldUpdater#compareAndSet'nin JIT derlendiğinden emin olun) Pixel 5'te (API 33) aşağıdaki sonuçlar elde edilir:

 50.7 ns  atomicReference_compareAndSet
135   ns  atomicRef_compareAndSet

Ölçümler, farkı doğrulamaktadır.kotlinx.atomicfu sürümü yaklaşık 2,7 kat daha yavaştır. Bu durum, ART'nin gizli optimizasyon yapmadığını ve yansıtmalı erişim kontrollerinin çalışma zamanı sırasında gerçek ek yük oluşturduğunu doğrular.

Orijinal yöntem izine geri dönüp baktığımızda, AtomicReferenceFieldUpdater tarafından gerçekleştirilen tek anlamlı işin, temel atomik işlemi gerçekten yürüten Unsafe.getObjectVolatile yöntemine yapılan dahili çağrı olduğunu görürüz. Çoğu durumda, güncelleyici başlatıcı statiktir ve çevreleyen sınıfın yapısına göre her zaman doğru olduğu kanıtlanabilir. Bu nedenle, AtomicReferenceFieldUpdater kullanımlarının çoğu statik olarak analiz edilebilir ve derleme sırasında dahili bir Unsafe varyantıyla değiştirilebilir. Android derleme araç zincirinin de tam olarak bunu yapabilen kendi optimizasyon derleyicisi vardır.

R8 ile optimizasyon

Atomic*FieldUpdater sınıfları; belirsiz, dinamik ve yansımaya dayalı kullanımı destekler ancak genellikle statik olarak belirgin kalıplarda kullanılır. Bu durum, hem yavaş referans performansını hem de optimizasyon isteğini açıklar. R8, tam program optimizasyonu yapan bir derleyicidir ve yansıtmalı güvenlik kontrollerinin ek yükünü azaltmak için daha basit kalıpları incelemeye uygundur. R8, Java veya Kotlin derleyicisinden sonra JVM bayt kodunu alır ancak okunabilirliği kolaylaştırmak için bu örnekler Java söz diziminde sunulur. Bu nedenle AtomicReferenceFieldUpdater için tür bağımsız değişkenleri yoktur.

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

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

Temel örnek, tutucu, tür ve alan adı için basit sabit bağımsız değişkenlerle değişken bir alana erişen statik bir nihai güncelleyici oluşturur. Kullanılan yansıma tamamen şeffaftır. Bu güncelleyicinin geçerli bir alana referans verdiği ve güncelleyici oluşturma sitesinin alana geçerli erişimi olduğu açıkça görülmektedir.

Özünde Atomic*FieldUpdater, bir alan ofsetini ve Unsafe çağrılarını sarmalayan bir yapıdır. Optimizasyon için en iyi senaryo, güncelleyici alanını bir ofset alanıyla değiştirmek ve güncelleyici çağrılarını Unsafe çağrılarıyla değiştirmektir.

Atomic*FieldUpdater'ı optimize etme

Optimizasyon üç bölüm halinde uygulanır: Araçlandırma, Değiştirme ve Temizleme. 

Araçlar

İlk adım, Unsafe çağrısı aracılığıyla doğrudan erişimi kolaylaştırmak için güncelleyici alanının yanı sıra ofset alanları eklemektir.

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

Alana yansıma yoluyla erişilir ve sınıftaki alan ofsetini ayıklamak için Unsafe kullanılır. Bu kod, yansıma doğrulamasını dikkate almazsanız Atomic*FieldUpdater öğesinin dahili bileşenlerini temsil eder. Bunun yerine, güncelleyicinin tutucu türü ve geçici alanın alan türü, derleyicide statik olarak izlenir.

 

Orijinal alanın ve başlatma işleminin olduğu gibi bırakıldığını unutmayın. Optimizasyon işlemi, kullanımları iyimser bir şekilde kolaylaştırıp optimize eder ve daha sonra temizler. Bu, uygulama için basit bir yaklaşımdır ancak aynı zamanda güncelleyici alanlarının kısmi optimizasyonuna da olanak tanır. Bu durumda bazı kullanımlar olduğu gibi bırakılırken diğerleri optimize edilir.

Değiştirme

Derleyicinin bu noktasında, uygun bir eşzamanlılık birleştirme noktasından sonra, araçlı güncelleyici alanlarının bir listesi bulunur. Bu, her çağrı sitesini birkaç koşula göre ayrı ayrı optimize edebileceğimiz anlamına gelir. Örnek bir çağrıyı inceleyelim:

updater.compareAndSet(holder, expectedValue, newValue);

Atomic*FieldUpdater için gereken koşullar şunlardır:

  • updater, ölçümlü bir alandan mı geliyor? Yani statik analiz, nesnenin değerini izleyerek, araçlı bir güncelleyicinin alan okuma işlemine kadar geri gidebilir mi?
  • holder, orijinal olarak tanımlanan sahip türüyle aynı sınıf mı yoksa onun bir alt sınıfı mı?
  • newValue, orijinal olarak tanımlanan alan türüyle aynı sınıf mı yoksa onun bir alt sınıfı mı?

Tüm koşullar karşılanırsa çağrı, yansıma kontrolleri olmadan Unsafe çağrısıyla değiştirilir.

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

Bu yeni çağrı daha hızlı ve basittir ancak updater ve holder içindeki boş değerleri işleme şekli açısından orijinal çağrıdan farklıdır. Statik olarak hariç tutulmadığı sürece her ikisi için de null-check'ler eklenir. 

Temizleme

Bu noktada, tutma sınıfı orijinal güncelleyici alanına ve yeni ofset alanına sahiptir. Ayrıca, bu iki alandan birini kullanabilecek çağrı siteleri de vardır. Çağrı sitelerinin hiçbiri optimize edilmediyse ofset alanı kaldırılmalıdır. Çağrı sitelerinin tümü optimize edildiyse güncelleyici alanı kaldırılmalıdır. Her iki durumda da başlatma çağrısı silinmelidir. Kullanılmayan alanların silinmesi ve ölü kodların kaldırılması derleyicide zaten yapılıyor ancak başlatma kodunu buradan kaldırmak için birkaç işlem daha yapılması gerekiyor.

Hem newUpdater hem de getDeclaredField çağrısı, istisnalar oluşturabileceğinden yan etkilere neden olabilir (ve bunların uygulanması da API sürümüne bağlı olduğundan bilinmez). Bu nedenle, genel optimizasyonla güvenli bir şekilde kaldırılamazlar. Bu nedenle, bu temizleme işlemi için, statik olarak istisna içermediği bilinen araçlı alanların açıkça dikkate alınması gerekiyordu.

Sonuç olarak, yukarıda gösterilen basit güncelleyici örneği optimizasyondan sonra şu şekilde görünür:

Sonuçlar

Bu optimizasyonlardan sonra kotlinx.atomicfu ve AtomicInt/Long/ReferenceFieldUpdater'in çoğu açık kullanımı, R8 uygulandığında AtomicReference performansıyla eşleşir. Hatta bazı karşılaştırmalarda daha da hızlıdır. kotlinx.atomicfu, atomic örneklerini alanlara satır içi olarak ekleyebilen bir derleyici eklentisine sahiptir. Bu eklenti, atomik olarak güncellenen bir alan oluşturmak için gereken ayırmaları azaltır.

Bu çalışmadan en çok Jetpack Compose yararlandı. Compose çalışma zamanında, performans gerilemelerini erken yakalamak için eş yordam performansını çok yakından takip eden bir dizi mikro karşılaştırma vardır. Karşılaştırmalar yeni bir R8 sürümüne güncellendiğinde, LaunchedEffect uygulamasında eş yordamları başlatıp iptal ederken 2 kat iyileşme olduğunu fark ettik.

pic03_enhanced.png
LaunchedEffect'te eş yordamları başlatırken ve iptal ederken geçen süreyi gösteren karşılaştırma grafiği (düşük değerler daha iyidir). Grafikteki değişiklik, R8 güncellemesine karşılık geliyor ve 2 kat iyileşme gösteriyor.

Bunun dışında, ART ekibi bu optimizasyonları yerel olarak sanal makine düzeyinde uyguluyor. Uygulamanız API 36'yı hedefliyorsa ve Android'in yeni bir sürümünde çalışıyorsa cihazınız eş yordamları zaten benzer bir şekilde optimize ediyor olabilir. Yukarıdaki eş yordam karşılaştırmaları, ART'nin son sürümlerindeki JIT güncellemelerinden sonra performansta yaklaşık% 15 iyileşme olduğunu gösterdi.

Uygulamanız, AGP 9.2.0'ye yükseltildiğinde veya doğrudan R8 9.2.0 kullanıldığında bu optimizasyonu varsayılan olarak alır. Daha fazla bilgi için D8 dexer ve R8 shrinker başlıklı makaleyi inceleyin.

Okumaya devam et