केस स्टडी

R8 ने Android पर Kotlin कोरूटीन को दो गुना तेज़ कैसे बनाया

पढ़ने में 7 मिनट लगेंगे
जोनाथन स्टारअप की प्रोफ़ाइल देखें आंद्रेई शिकोव की प्रोफ़ाइल देखें
Jonathan Starup & Andrei Shikov

AGP 9.2.0 से, R8, ज़्यादातर Atomic*FieldUpdater कॉल को Unsafe वैरिएंट में ऑप्टिमाइज़ करता है. ये वैरिएंट, सामान्य कार्रवाइयों में दो से चार गुना बेहतर परफ़ॉर्म करते हैं. इसका असर खास तौर पर kotlinx.atomicfu लाइब्रेरी पर पड़ता है. यह लाइब्रेरी, kotlinx.coroutines के लिए ऐटॉमिक लागू करती है. इससे को-रूटीन को लॉन्च और रद्द करने की प्रोसेस, दो गुना तेज़ हो जाती है. इन फ़ायदों को पाने के लिए, अपने एजीपी को 9.2.0 या इसके बाद के वर्शन पर अपडेट करें.

ज़्यादातर Android ऐप्लिकेशन में Kotlin को मुख्य भाषा के तौर पर इस्तेमाल किया जा रहा है. इसलिए, kotlinx.coroutines एसिंक्रोनस प्रोग्रामिंग के लिए एक डिफ़ॉल्ट स्टैंडर्ड बन गया है. यह लाइब्रेरी, एक साथ कई फ़्लो को मैनेज करने का एक बेहतरीन और व्यवस्थित तरीका उपलब्ध कराती है. यह Kotlin के लिए नेटिव है. Jetpack Compose भी इससे अलग नहीं था. इसमें पॉइंटर इवेंट, ऐनिमेशन, और अन्य इंटरैक्शन को मैनेज करने के लिए को-रूटीन का इस्तेमाल किया गया. लिखने के समय, Compose में एक साथ काम करने वाले ज़्यादातर एपीआई, बैकग्राउंड में suspend फ़ंक्शन को कॉल करते हैं. साथ ही, अपडेट को मैनेज करने के लिए को-रूटीन लॉन्च और/या रद्द करते हैं.

Compose टीम ने परफ़ॉर्मेंस की जांच शुरू की. इस दौरान, यह पता चला कि कंपोज़िशन के बाहर होने वाले कई ऑपरेशनों के लिए, कोरूटीन एक बॉटलनेक है. उदाहरण के लिए, Modifier.clickable को बनाने और अपडेट करने में लगने वाले समय का 80% हिस्सा, InteractionSource अपडेट को मैनेज करने वाली इंटरनल कोरूटीन को लॉन्च करने और रद्द करने में लगा. इन ऑब्ज़र्वेशन के आधार पर, शुरुआती परफ़ॉर्मेंस को बेहतर बनाने के लिए किए गए ज़्यादातर काम में, डिफ़ॉल्ट पाथ से कोरूटीन हटाने और ज़रूरी होने तक इनिशियलाइज़ेशन में देरी करने पर फ़ोकस किया गया था. 

कोरूटीन की लागत

Android पर किसी फ़ंक्शन के इंटरनल व्यवहार का विश्लेषण करने का सबसे आसान तरीका है, Android Runtime (ART) के तरीके को ट्रेस करना. एआरटी मेथड ट्रेस, एक ऐसा टूल है जो किसी ऐप्लिकेशन के एक्ज़ीक्यूशन फ़्लो को रिकॉर्ड करता है. इससे यह पता चलता है कि कौनसे तरीके कॉल किए गए, उनका क्रम क्या था, और हर तरीके में कितना समय लगा. इससे डेवलपर को परफ़ॉर्मेंस से जुड़ी समस्याओं का पता लगाने में मदद मिलती है. बिना डेटा वाली LaunchedEffect { } कॉल के लिए, यह कुछ इस तरह दिखेगा:

pic01_enhanced.png
Perfetto यूज़र इंटरफ़ेस (यूआई) में, LaunchedEffect तरीके के ट्रेस को विज़ुअलाइज़ किया गया है

ऊपर दिए गए तरीके के ट्रेस को तीन हिस्सों में बांटा जा सकता है:

  • नया कोरूटीन शुरू किया जा रहा है
  • कोरूटीन शुरू किया जा रहा है
  • को-रूटीन पूरा हो रहा है (क्योंकि यह तुरंत बंद हो जाता है)

LaunchedEffect रद्द करना, सामान्य तौर पर पूरा करने जैसा ही है. हालांकि, इससे CancellationException भी बनता है.

ऊपर दी गई प्रोफ़ाइल में, एक चीज़ तुरंत संदिग्ध लगती है. वह यह है कि java.util.concurrent.AtomicReferenceFieldUpdater (j… लेबल वाले बैंगनी या हरे बॉक्स) में बार-बार कॉल किए गए हैं. हर कॉल में कम समय लगता है, लेकिन कॉल की फ़्रीक्वेंसी चिंता का विषय है. अगर कई बार कॉल करने पर, परफ़ॉर्मेंस पर थोड़ा भी असर पड़ता है, तो इससे परफ़ॉर्मेंस में काफ़ी गिरावट आ सकती है. कॉल को ज़ूम इन करने पर पता चलता है कि ज़्यादातर समय... रिफ़्लेक्शन की जांच में लगता है?

pic02-enhanced.png
LaunchedEffect के शुरू होने के दौरान, AtomicReferenceFieldUpdater.get के तरीके के ट्रेस की बारीकी से जांच करना

को-रूटीन, पैरंट-चाइल्ड रिलेशनशिप के लिए लॉक-फ़्री ट्री स्ट्रक्चर लागू करते हैं. इससे स्ट्रक्चर्ड कंकरेंसी मुमकिन हो पाती है. kotlinx.atomicfu लाइब्रेरी, लॉक-फ़्री ऐटॉमिक ऑपरेशनों को लागू करती है. इसके लिए, यह जेवीएम प्रिमिटिव AtomicReferenceFieldUpdater का इस्तेमाल करती है. अपडेटर, क्लास के रेफ़रंस और फ़ील्ड के नाम का इस्तेमाल करके, रनटाइम पर ऐटॉमिक ऑपरेशन करता है. साथ ही, इसे कई रिफ़्लेक्टिव सुरक्षा जांचें करनी होती हैं, ताकि यह पक्का किया जा सके कि फ़ील्ड मौजूद है और उस तक पहुंचा जा सकता है. कोरूटीन में हर ऑपरेशन (शुरू करना, रोकना, रद्द करना, पूरा करना) कम से कम एक ऐटॉमिक ऑपरेशन को कॉल करता है. इसलिए, अगर यह धीमा है, तो कोरूटीन ठीक से काम नहीं करेंगे.

AtomicReferenceFieldUpdater की जांच करना

हालांकि, हमें अभी से ही नतीजे के बारे में नहीं सोचना चाहिए. AtomicReferenceFieldUpdater को पिछले 10 सालों से JVM पर अच्छी तरह से ऑप्टिमाइज़ किया गया है. साथ ही, ऐसा हो सकता है कि मेथड ट्रेस, वीएम लेवल के ऑप्टिमाइज़ेशन से पूरी तरह से हटाए गए ओवरहेड को कैप्चर कर लें. जैसे, जस्ट-इन-टाइम (जेआईटी) या ऐड-ऑफ-टाइम (एओटी) कंपाइलेशन. परफ़ॉर्मेंस की पुष्टि करने के लिए, आइए कुछ बेंचमार्क लिखें. इनसे kotlinx.atomicfu और 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 */
}

Pixel 5 पर इस बेंचमार्क को चलाने पर, ये नतीजे मिलते हैं. इस दौरान, यह पक्का किया जाता है कि वार्मअप के दौरान AtomicReferenceFieldUpdater#compareAndSet को JIT कंपाइल किया गया हो. ये नतीजे Pixel 5 (API 33) पर मिले हैं:

 50.7 ns  atomicReference_compareAndSet
135   ns  atomicRef_compareAndSet

मेज़रमेंट से इस अंतर की पुष्टि होती है. साथ ही, kotlinx.atomicfu वर्शन, 2.7 गुना धीमा है. इससे पुष्टि होती है कि एआरटी कोई भी छिपा हुआ ऑप्टिमाइज़ेशन नहीं करता है. साथ ही, रिफ़्लेक्टिव ऐक्सेस की जांच करने से रनटाइम के दौरान असली ओवरहेड जुड़ जाता है.

ओरिजनल तरीके के ट्रेस पर वापस जाने पर, AtomicReferenceFieldUpdater ने सिर्फ़ एक काम किया है. वह है Unsafe.getObjectVolatile में इंटरनल कॉल करना. इससे एटॉमिक ऑपरेशन को असल में लागू किया जाता है. ज़्यादातर मामलों में, अपडेटर इनिशियलाइज़र स्टैटिक होता है. साथ ही, इसे आस-पास की क्लास के स्ट्रक्चर के आधार पर हमेशा सही साबित किया जा सकता है. इसलिए, कंपाइल करने के दौरान, AtomicReferenceFieldUpdater के ज़्यादातर इस्तेमाल का स्टैटिक विश्लेषण किया जा सकता है. साथ ही, उन्हें Unsafe के इंटरनल वैरिएंट से बदला जा सकता है. Android के बिल्ड टूलचेन में, ऑप्टिमाइज़ करने वाला अपना कंपाइलर होता है. यह कंपाइलर, इस काम को आसानी से कर सकता है.

R8 की मदद से ऑप्टिमाइज़ेशन

Atomic*FieldUpdater क्लास में, डाइनैमिक और रिफ़्लेक्शन पर आधारित स्टाइल का इस्तेमाल किया जा सकता है. हालांकि,  इनका इस्तेमाल अक्सर स्टैटिक पैटर्न में किया जाता है. इससे, बेसलाइन परफ़ॉर्मेंस के कम होने और ऑप्टिमाइज़ेशन की ज़रूरत के बारे में पता चलता है. R8, पूरे प्रोग्राम को ऑप्टिमाइज़ करने वाला कंपाइलर है. यह रिफ़्लेक्टिव सुरक्षा जांच के ओवरहेड को कम करने के लिए, आसान पैटर्न को समझने में बेहतर है. R8 को Java या Kotlin कंपाइलर के बाद JVM बाइटकोड मिलता है. हालांकि, इन्हें आसानी से समझने के लिए, Java सिंटैक्स में दिखाया गया है. इसलिए, AtomicReferenceFieldUpdater के लिए कोई टाइप आर्ग्युमेंट नहीं है.

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

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

इस बुनियादी उदाहरण में, एक स्टैटिक फ़ाइनल अपडेटर बनाया गया है. यह होल्डर, टाइप, और फ़ील्ड के नाम के लिए, सामान्य कॉन्स्टेंट आर्ग्युमेंट के साथ एक अस्थिर फ़ील्ड को ऐक्सेस करता है. इसमें इस्तेमाल किया गया रिफ़्लेक्शन पूरी तरह से पारदर्शी है. अपडेट करने वाले इस फ़ील्ड में मान्य रेफ़रंस मौजूद है. साथ ही, अपडेट करने वाले की साइट के पास इस फ़ील्ड का मान्य ऐक्सेस है.

असल में, Atomic*FieldUpdater, फ़ील्ड ऑफ़सेट के चारों ओर एक रैपर होता है और Unsafe को कॉल करता है. ऑप्टिमाइज़ेशन के लिए सबसे सही तरीका यह है कि अपडेटर फ़ील्ड को ऑफ़सेट फ़ील्ड से बदल दिया जाए. साथ ही, अपडेटर कॉल को Unsafe के कॉल से बदल दिया जाए.

Optimizing Atomic*FieldUpdater

ऑप्टिमाइज़ेशन को तीन हिस्सों में लागू किया जाता है: इंस्ट्रूमेंटेशन, रिप्लेसमेंट, और क्लीन-अप. 

इंस्ट्रुमेंटेशन

पहला चरण, अपडेटर फ़ील्ड के साथ-साथ ऑफ़सेट फ़ील्ड को भी शामिल करना है, ताकि Unsafe कॉल के ज़रिए सीधे तौर पर ऐक्सेस किया जा सके.

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

इस फ़ील्ड को रिफ़्लेक्शन के ज़रिए ऐक्सेस किया जाता है. साथ ही, क्लास में फ़ील्ड ऑफ़सेट निकालने के लिए Unsafe का इस्तेमाल किया जाता है. अगर रिफ़्लेक्शन की पुष्टि करने की सुविधा को अनदेखा किया जाता है, तो यह कोड Atomic*FieldUpdater के इंटरनल को दिखाता है. इसके बजाय, अपडेटर के होल्डर टाइप और अस्थिर फ़ील्ड के फ़ील्ड टाइप को कंपाइलर में स्टैटिक तौर पर ट्रैक किया जाता है.

 

ध्यान दें कि ओरिजनल फ़ील्ड और उसके इनिशियलाइज़ेशन को पहले जैसा ही रखा जाता है. ऑप्टिमाइज़ेशन की प्रोसेस, इस्तेमाल को बेहतर बनाने और ऑप्टिमाइज़ करने में मदद करती है. इसके बाद, इसे क्लीन अप कर देती है. यह लागू करने का एक आसान तरीका है. हालांकि, इससे अपडेटर फ़ील्ड को आंशिक रूप से ऑप्टिमाइज़ किया जा सकता है. इसमें कुछ फ़ील्ड को पहले जैसा ही रखा जाता है, जबकि कुछ को ऑप्टिमाइज़ किया जाता है.

प्रतिस्थापन

कंपाइलर में इस पॉइंट पर, एक साथ कई काम करने की सुविधा वाले सही जॉइन पॉइंट के बाद, हमारे पास इंस्ट्रुमेंट किए गए अपडेटर फ़ील्ड की सूची होती है. इसका मतलब है कि हम कुछ शर्तों के आधार पर, हर कॉल साइट को अलग-अलग तरीके से ऑप्टिमाइज़ कर सकते हैं. कॉल का यह उदाहरण देखें:

updater.compareAndSet(holder, expectedValue, newValue);

Atomic*FieldUpdater के लिए ये शर्तें पूरी करना ज़रूरी है:

  • क्या updater, इंस्ट्रुमेंट किए गए फ़ील्ड से मिला है? इसका मतलब है कि क्या स्टैटिक विश्लेषण, ऑब्जेक्ट की वैल्यू को इंस्ट्रुमेंट किए गए अपडेटर के फ़ील्ड रीड तक ट्रैक कर सकता है?
  • क्या holder, होल्डर टाइप के तौर पर मूल रूप से तय की गई क्लास की तरह ही है या उसकी सब-क्लास है?
  • क्या newValue, मूल रूप से तय किए गए फ़ील्ड टाइप की क्लास या सब-क्लास है?

अगर सभी शर्तें पूरी होती हैं, तो कॉल को Unsafe पर किए गए कॉल से बदल दिया जाता है. इसके लिए, रिफ़्लेक्शन की जांच नहीं की जाती.

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

यह नया कॉल, पहले वाले कॉल की तुलना में ज़्यादा तेज़ और आसान है. हालांकि, updater और holder में शून्य वैल्यू को हैंडल करने के मामले में, यह ओरिजनल कॉल से अलग है. जब तक स्टैटिक तौर पर यह तय न हो जाए कि वैल्यू शून्य नहीं है, तब तक दोनों के लिए शून्य की जांच की जाती है. 

मिटाना

इस समय, होल्डिंग क्लास में ओरिजनल अपडेटर फ़ील्ड और नया ऑफ़सेट फ़ील्ड होता है. साथ ही, कॉल साइटें होती हैं, जो इन दोनों में से किसी एक का इस्तेमाल कर सकती हैं. अगर कॉल करने की किसी भी साइट को ऑप्टिमाइज़ नहीं किया गया है, तो ऑफ़सेट फ़ील्ड को हटा दिया जाना चाहिए. अगर कॉल करने की सभी साइटों को ऑप्टिमाइज़ किया गया है, तो अपडेटर फ़ील्ड को हटा दिया जाना चाहिए. दोनों ही मामलों में, कॉल शुरू करने की जानकारी भी मिटा दी जानी चाहिए. कंपाइलर में, इस्तेमाल न किए गए फ़ील्ड को मिटाने और डेड कोड को हटाने का काम पहले ही हो चुका है. हालांकि, यहां पर इनिशियलाइज़ेशन कोड को हटाने के लिए, कुछ और तरकीबों की ज़रूरत होती है.

newUpdater और getDeclaredField, दोनों को कॉल करने से साइड इफ़ेक्ट हो सकते हैं, क्योंकि ये अपवाद थ्रो कर सकते हैं. साथ ही, इनके लागू होने के बारे में भी पता नहीं है, क्योंकि यह एपीआई वर्शन पर निर्भर करता है. इसका मतलब है कि सामान्य ऑप्टिमाइज़ेशन से, इन्हें सुरक्षित तरीके से नहीं हटाया जा सकता. इसलिए, इस क्लीन-अप के लिए इंस्ट्रुमेंट किए गए फ़ील्ड पर साफ़ तौर पर ध्यान देना ज़रूरी था, क्योंकि ये फ़ील्ड स्टैटिक तौर पर अपवादों से मुक्त होते हैं.

ऑप्टिमाइज़ेशन के बाद, ऊपर दिखाया गया सामान्य अपडेटर का उदाहरण ऐसा दिखता है:

नतीजे

इन ऑप्टिमाइज़ेशन के बाद, kotlinx.atomicfu और AtomicInt/Long/ReferenceFieldUpdater के ज़्यादातर इस्तेमाल अब R8 के साथ AtomicReference की परफ़ॉर्मेंस से मेल खाते हैं. असल में, यह कुछ बेंचमार्क में और भी तेज़ है. kotlinx.atomicfu में एक कंपाइलर प्लगिन होता है, जो atomic इंस्टेंस को फ़ील्ड में इनलाइन कर सकता है. इससे, एटॉमिक तौर पर अपडेट किए गए फ़ील्ड को बनाने के लिए ज़रूरी ऐलोकेशन कम हो जाते हैं.

इस काम से Jetpack Compose को सबसे ज़्यादा फ़ायदा मिला. Compose रनटाइम में कई माइक्रोबेंचमार्क होते हैं. ये को-रूटीन की परफ़ॉर्मेंस को बारीकी से ट्रैक करते हैं, ताकि परफ़ॉर्मेंस में गिरावट का पता लगाया जा सके. जब बेंचमार्क को R8 के नए वर्शन पर अपडेट किया गया, तो हमने देखा कि LaunchedEffect में कोरूटीन लॉन्च करने और रद्द करने के दौरान, दो गुना सुधार हुआ है!

pic03_enhanced.png
लॉन्च किए गए इफ़ेक्ट में कोरूटीन शुरू करने और रद्द करने में लगने वाले समय को दिखाने वाला बेंचमार्क ग्राफ़ (कम समय बेहतर होता है). ग्राफ़ में हुआ बदलाव, R8 अपडेट के मुताबिक है. इसमें दो गुना सुधार दिखाया गया है.

इसके अलावा, एआरटी टीम इन ऑप्टिमाइज़ेशन को वीएम लेवल पर लागू कर रही है. अगर आपका ऐप्लिकेशन, एपीआई 36 को टारगेट कर रहा है और Android के नए वर्शन पर चल रहा है, तो हो सकता है कि आपका डिवाइस पहले से ही इसी तरह से को-रूटीन को ऑप्टिमाइज़ कर रहा हो. ऊपर दिए गए कोरूटीन बेंचमार्क में, ART के हाल के वर्शन में JIT अपडेट के बाद, परफ़ॉर्मेंस में ~15% का सुधार देखा गया.

AGP 9.2.0 पर अपग्रेड करने या सीधे तौर पर R8 9.2.0 का इस्तेमाल करने पर, आपके ऐप्लिकेशन को यह ऑप्टिमाइज़ेशन डिफ़ॉल्ट रूप से मिलेगा. ज़्यादा जानकारी के लिए, D8 dexer और R8 shrinker देखें.

लेखक:
पढ़ना जारी रखें