دراسات الحالة

كيف ساهمت أداة R8 في تسريع "كوروتينات" Kotlin على Android بمقدار الضعف؟

يستغرق الاطّلاع على المقال 7 دقائق

اعتبارًا من الإصدار 9.2.0 من المكوّن الإضافي لنظام Gradle المتوافق مع Android، يحسِّن R8 معظم استدعاءات Atomic*FieldUpdater إلى متغيرات Unsafe التي تعمل بمعدّل أفضل من مرتين إلى أربع مرات في العمليات الشائعة. ويؤثّر ذلك بشكل كبير في مكتبة kotlinx.atomicfu التي تنفّذ عمليات ذرية للنوع kotlinx.coroutines، ما يجعل تشغيل وإلغاء الروتينات الفرعية أسرع بمقدار مرتين. للاستفادة من المزايا، عليك تحديث الإصدار الحالي من المكوّن الإضافي Android Gradle إلى الإصدار 9.2.0 أو إصدار أحدث.

مع اعتماد غالبية تطبيقات Android لغة Kotlin كلغة أساسية، أصبحت مكتبة kotlinx.coroutines معيارًا فعليًا للبرمجة غير المتزامنة. توفّر المكتبة طريقة منظَّمة ومصمَّمة جيدًا لإدارة عمليات التنفيذ المتزامنة التي تتوافق مع لغة Kotlin. ولم يكن Jetpack Compose استثناءً، فقد اعتمد على الروتينات الفرعية لإدارة أحداث المؤشر والصور المتحركة والتفاعلات الأخرى. في وقت كتابة هذا المستند، تستدعي معظم واجهات برمجة التطبيقات المتزامنة في Compose دوال suspend في الخلفية، وتطلق و/أو تلغي إجراءات روتينية للتعامل مع التعديلات.

عندما بدأ فريق Compose في التحقيق في الأداء، تبيّن أنّ الكوروتينات تشكّل مؤثِّرًا سلبيًا للعديد من العمليات التي تحدث خارج عملية التكوين. على سبيل المثال، تم استهلاك% 80 من الوقت المستغرَق في إنشاء Modifier.clickable وتعديله من خلال تشغيل وإلغاء إجراءات روتينية داخلية متزامنة تعاملت مع تعديلات InteractionSource. واستنادًا إلى هذه الملاحظات، تركّزت معظم جهود تحسين الأداء المبكرة على إزالة الروتينات الفرعية من المسار التلقائي وتأخير عملية التهيئة إلى أن تصبح ضرورية. 

تكلفة الروتين الفرعي

أسهل طريقة لتحليل السلوك الداخلي لإحدى الدوال على Android هي تسجيل تتبُّع طريقة في "وقت تشغيل Android" (ART). سجلّ إجراءات ART هو أداة تسجّل تسلسل تنفيذ أحد التطبيقات، وتوضّح بالتحديد الطرق التي يتم استدعاؤها وترتيبها والوقت المستغرَق في كل طريقة، ما يتيح للمطوّرين تحديد المشاكل التي تؤدي إلى بطء الأداء. بالنسبة إلى استدعاء LaunchedEffect { } فارغ، سيبدو على النحو التالي:

pic01_enhanced.png
تمثّل بيانات تتبُّع طريقة LaunchedEffect بصريًا في واجهة مستخدم Perfetto

يمكن تقسيم سجلّ الإجراءات أعلاه إلى ثلاثة أجزاء:

  • بدء روتين فرعي جديد
  • بدء إجراء روتيني
  • إكمال الروتين الفرعي (لأنّه يتم إنهاؤه على الفور)

يشبه إلغاء LaunchedEffect الإكمال العادي، باستثناء أنّه يؤدي أيضًا إلى إنشاء CancellationException.

من الملف الشخصي أعلاه، أحد الأشياء التي تثير الشك على الفور هو المكالمات المتكررة إلى java.util.concurrent.AtomicReferenceFieldUpdater (المربّعات الأرجوانية أو الخضراء التي تحمل التصنيفات j…). على الرغم من أنّ كل طلب سريع نسبيًا، إلا أنّ التكرار يثير القلق، إذ إنّ أي تكلفة غير ضئيلة موزّعة على عمليات استدعاء متعددة قد تؤدي إلى تراجع ملحوظ. عند تكبير مكالمة، يتضح أنّ معظم الوقت يُستغرَق في... عمليات التحقّق من الانعكاس؟

pic02-enhanced.png
نظرة عن كثب على سجلّ الإجراءات لـ AtomicReferenceFieldUpdater.get أثناء عملية تهيئة LaunchedEffect

تنفّذ الروتينات المشتركة بنية شجرة غير قابلة للتأمين لعلاقات العنصر الرئيسي والعنصر الثانوي، ما يتيح إمكانية تنفيذ التزامن المنظَّم. تبيّن أنّ مكتبة kotlinx.atomicfu تنفّذ عمليات ذرية غير متزامنة باستخدام عنصر JVM أساسي معروف، وهو AtomicReferenceFieldUpdater. تستخدم أداة التحديث مرجع فئة واسم حقل لتنفيذ عمليات ذرية في وقت التشغيل، ويجب أن تنفّذ عدة عمليات تحقّق من الأمان الانعكاسي للتأكّد من توفّر الحقل وإمكانية الوصول إليه. يستدعي كل إجراء في الروتينات الفرعية (البدء والتعليق والإلغاء والإكمال) عملية ذرية واحدة على الأقل، لذا إذا كان بطيئًا، لن تعمل الروتينات الفرعية بشكل جيد.

التحقيق في AtomicReferenceFieldUpdater

ولكن لنستبق الأحداث. AtomicReferenceFieldUpdater في الواقع، تم تحسينها بشكل جيد على JVM لأكثر من 10 سنوات حتى الآن، وقد ترصد عمليات تتبُّع الطرق النفقات العامة التي تتم إزالتها بالكامل من خلال عملية تحسين على مستوى الجهاز الافتراضي: عمليات الترجمة الفورية (JIT) أو الترجمة المسبقة (AOT). للتحقّق من الأداء، لنكتب بعض مقاييس الأداء لقياس الفرق بين المراجع الذرية من 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 يتم تجميعه في الوقت المناسب أثناء عملية الإحماء) إلى تحقيق النتائج التالية على هاتف Pixel 5 (الإصدار 33 من واجهة برمجة التطبيقات):

 50.7 ns  atomicReference_compareAndSet
135   ns  atomicRef_compareAndSet

تؤكّد القياسات وجود الفجوة، حيث أنّ الإصدار kotlinx.atomicfu أبطأ بحوالي 2.7 مرة. يؤكّد هذا أنّ ART لا يجري أي تحسينات مخفية، وأنّ عمليات التحقّق من إذن الوصول الانعكاسي تضيف عبئًا حقيقيًا أثناء وقت التشغيل.

بالرجوع إلى سجلّ الإجراءات الأصلي، نجد أنّ العمل الوحيد المهم الذي تنفّذه AtomicReferenceFieldUpdater هو الطلب الداخلي إلى Unsafe.getObjectVolatile الذي ينفّذ العملية الذرية الأساسية. في معظم الحالات، يكون برنامج تهيئة أداة التحديث ثابتًا، ويمكن إثبات صحته دائمًا استنادًا إلى بنية الفئة المحيطة. وبالتالي، يمكن تحليل معظم استخدامات AtomicReferenceFieldUpdater بشكل ثابت واستبدالها بمتغير Unsafe داخلي أثناء التجميع. يحدث أيضًا أنّ سلسلة أدوات إنشاء Android تتضمّن برنامجًا مجمّعًا للتحسين خاصًا بها يمكنه تنفيذ ذلك بالضبط.

التحسين باستخدام R8

تتيح فئات Atomic*FieldUpdater الاستخدام الدقيق والديناميكي والقائم على الانعكاس، ولكن غالبًا ما تُستخدَم في أنماط واضحة ثابتة. ويفسّر ذلك كلاً من الأداء الأساسي البطيء والحاجة إلى التحسين. ‫R8 هو برنامج تجميع وتحسين كامل، وهو مناسب تمامًا لفهم الأنماط الأبسط من أجل تقليل النفقات العامة لعمليات فحص الأمان المستندة إلى الانعكاس. يتلقّى R8 رمز بايت JVM بعد برنامج تجميع Java أو Kotlin، ولكن لتسهيل القراءة، يتم عرض هذه الأمثلة في بنية 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.

تحسين 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 الآن مع AtomicReference عند تطبيق R8. في الواقع، يكون أسرع في بعض مقاييس الأداء، إذ يحتوي kotlinx.atomicfu على مكوّن إضافي لبرنامج التجميع يمكنه تضمين مثيلات atomic في الحقول، ما يقلّل من عمليات التخصيص المطلوبة لإنشاء حقل يتم تعديله بشكل ذري.

كانت Jetpack Compose هي المستفيد الرئيسي من هذا العمل. يتضمّن وقت تشغيل Compose عددًا من الاختبارات المعيارية الصغيرة التي تتتبّع أداء الروتينات الفرعية عن كثب لرصد أي تراجع في الأداء في وقت مبكر. عندما تم تعديل مقاييس الأداء إلى إصدار جديد من R8، لاحظنا تحسّنًا بمقدار الضعف عند تشغيل وإلغاء الروتينات الفرعية في LaunchedEffect!

pic03_enhanced.png
رسم بياني لمقياس الأداء يوضّح الوقت المستغرَق عند بدء وإلغاء الروتينات الفرعية في LaunchedEffect (كلما كان الوقت أقل كان ذلك أفضل). يتوافق التغيير في الرسم البياني مع تحديث R8، ما يشير إلى تحسُّن بمقدار الضعف.

إلى جانب ذلك، يعمل فريق ART على تنفيذ عمليات التحسين هذه بشكلٍ أصلي على مستوى الجهاز الافتراضي. إذا كان تطبيقك يستهدف واجهة برمجة التطبيقات 36 ويعمل على إصدار حديث من Android، من المحتمل أنّ جهازك يحسِّن بالفعل إجراءات coroutines بطريقة مشابهة. وقد سجّلت مقاييس الأداء المرجعية لبرامج coroutine أعلاه تحسُّنًا بنسبة% 15 تقريبًا في الأداء بعد تحديثات JIT في أحدث إصدارات ART.

سيتم تطبيق هذا التحسين تلقائيًا على تطبيقك عند الترقية إلى الإصدار 9.2.0 من Android Gradle Plugin أو باستخدام الإصدار 9.2.0 من R8 مباشرةً. لمزيد من المعلومات، يُرجى الاطّلاع على أداة D8 لتحويل الرمز البرمجي إلى رمز dex وأداة R8 لتقليل حجم الرمز.

متابعة القراءة