مطالعات موردی

چگونه R8 سرعت «روال‌های مشترک Kotlin» را در Android دوبرابر کرد

زمان خواندن: ۷ دقیقه
مشاهده نمایه Jonathan Starup مشاهده نمایه Andrei Shikov
‫ Jonathan Starup و Andrei Shikov

از AGP نسخه ۹.۲.۰،‏ R8 اکثر فراخوانی‌های Atomic*FieldUpdater را به انواع Unsafe بهینه‌سازی می‌کند که در عملیات‌های رایج ۲ تا ۴ برابر بهتر عمل می‌کنند. این امر به‌ویژه تأثیر زیادی بر کتابخانه kotlinx.atomicfu دارد که اتمیک‌ها را برای kotlinx.coroutines پیاده‌سازی می‌کند و راه‌اندازی و لغو روال‌های هم‌زمان را تا ۲ برابر سریع‌تر می‌کند. برای دریافت مزایا، AGP را به نسخه ۹.۲.۰ یا بالاتر به‌روز کنید.

با پذیرش Kotlin به‌عنوان زبان اصلی انتخابی اکثر برنامه‌های Android، kotlinx.coroutines به یک استاندارد واقعی برای برنامه‌نویسی ناهمگام تبدیل شده است. این کتابخانه روشی با طراحی و ساختار مناسب برای مدیریت جریان‌های هم‌زمان ارائه می‌دهد که بومی Kotlin است. Jetpack Compose نیز از این قاعده مستثنی نبود و برای مدیریت رویدادهای اشاره‌گر، پویانمایی‌ها، و سایر تعاملات از کوروتین‌ها استفاده کرد. در زمان نوشتن، اکثر میاناهای برنامه‌سازی کاربردی همزمان در Compose توابع suspend را در پشت صحنه فراخوانی می‌کنند و برای مدیریت به‌روزرسانی‌ها، کوروتین‌ها را راه‌اندازی و/یا لغو می‌کنند.

وقتی تیم Compose شروع به بررسی عملکرد کرد، مشخص شد که کوروتین‌ها برای بسیاری از عملیاتی که خارج از ترکیب‌بندی اتفاق می‌افتند، گلوگاه هستند. به‌عنوان مثال، ۸۰٪ از زمان صرف‌شده برای ایجاد و به‌روزرسانی 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 برای بیش‌از ۱۰ سال به‌خوبی بهینه‌سازی شده است و گزارش ردیابی روش‌ها ممکن است سرباری را ضبط کند که کاملاً توسط بهینه‌سازی سطح VM حذف شده است: کامپایل‌های به‌موقع (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 درطول آماده‌سازی JIT کامپایل شده است)، نتایج زیر را در Pixel 5 (میانای برنامه‌سازی کاربردی ۳۳) به‌دست می‌دهد:

 50.7 ns  atomicReference_compareAndSet
135   ns  atomicRef_compareAndSet

اندازه‌گیری‌ها این فاصله را تأیید می‌کند و نسخه kotlinx.atomicfu به‌وضوح تقریباً ۲٫۷ برابر کندتر است. این موضوع تأیید می‌کند که 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 با فراخوانی اصلی متفاوت است. مگر اینکه به‌طور ایستا رد شود، بررسی‌های null برای هر دو درج می‌شود. 

پاک‌سازی

در این مرحله، کلاس نگه‌دارنده دارای فیلد به‌روزرسانی اصلی و فیلد آفست جدید به‌همراه سایت‌های فراخوانی است که ممکن است از یکی از این دو استفاده کنند. اگر هیچ‌کدام از سایت‌های فراخوانی بهینه‌سازی نشده باشند، فیلد افست باید برداشته شود و اگر همه سایت‌های فراخوانی بهینه‌سازی شده باشند، فیلد به‌روزرسانی باید برداشته شود. در هر دو مورد، تماس آغازین نیز باید حذف شود. حذف فیلدهای استفاده‌نشده و برداشتن کد مرده قبلاً در کامپایلر انجام شده است، اما برداشتن کد مقداردهی اولیه در اینجا به چند ترفند دیگر نیاز دارد.

هر دو فراخوانی به newUpdater و getDeclaredField ممکن است عوارض جانبی داشته باشند زیرا می‌توانند استثناهایی ایجاد کنند (و پیاده‌سازی آن‌ها نیز ناشناخته است زیرا به نسخه API بستگی دارد). این یعنی با بهینه‌سازی عمومی، نمی‌توان آن‌ها را به‌طور ایمن برداشت. بنابراین این پاک‌سازی نیازمند توجه صریح به فیلدهای ابزاردار بود، زیرا این فیلدها به‌صورت ایستا به‌عنوان فاقد استثنا شناخته می‌شوند.

درنهایت، نمونه به‌روزرسان ساده نشان‌داده‌شده در بالا پس‌از بهینه‌سازی به‌صورت زیر خواهد بود:

نتایج

پس‌از این بهینه‌سازی‌ها، kotlinx.atomicfu و اکثر موارد استفاده صریح از AtomicInt/Long/ReferenceFieldUpdater اکنون با عملکرد AtomicReference با اعمال R8 مطابقت دارند. درحقیقت، در برخی‌از محک‌ها حتی سریع‌تر است؛ kotlinx.atomicfu دارای یک افزونه کامپایلر است که می‌تواند نمونه‌های atomic را در فیلدها درون‌خطی کند و تخصیص‌های لازم برای ایجاد فیلد به‌روزرسانی‌شده اتمی را کاهش دهد.

Jetpack Compose ذینفع اصلی این کار بود. زمان اجرای Compose دارای تعدادی ریز محک است که عملکرد کوروتین را به‌دقت ردیابی می‌کند تا پس‌رفت‌های عملکرد را زودتر شناسایی کند. وقتی محک‌ها به نسخه جدید R8 به‌روزرسانی شدند، متوجه بهبود ۲ برابری در راه‌اندازی و لغو کردن کوروتین‌ها در LaunchedEffect شدیم!

pic03_enhanced.png
نمودار محک‌زنی که زمان صرف‌شده برای شروع و لغو روال‌های هم‌زمان در LaunchedEffect را نشان می‌دهد (هرچه کمتر باشد بهتر است). تغییر در نمودار مربوط به به‌روزرسانی R8 است که بهبود ۲ برابری را نشان می‌دهد.

علاوه‌براین، تیم ART این بهینه‌سازی‌ها را به‌طور بومی در سطح VM پیاده‌سازی می‌کند. اگر برنامه شما API 36 را هدف قرار می‌دهد و روی نسخه جدیدی از Android اجرا می‌شود، ممکن است دستگاه شما درحال‌حاضر روال‌های هم‌زمان را به روشی مشابه بهینه‌سازی کند. معیارهای هم‌روال بالا پس‌از به‌روزرسانی‌های JIT در نسخه‌های اخیر ART، حدود ۱۵٪ بهبود در عملکرد را نشان دادند.

برنامه شما هنگام ارتقا به AGP 9.2.0 یا بااستفاده مستقیم از R8 9.2.0، این بهینه‌سازی را به‌طور پیش‌فرض دریافت خواهد کرد. برای اطلاعات بیشتر، به D8 dexer and R8 shrinker مراجعه کنید.

نویسنده:
ادامه خواندن