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

چگونه R8 کوروتین‌های کاتلین را در اندروید دو برابر سریع‌تر کرد؟

۷ دقیقه مطالعه
مشاهده پروفایل جاناتان استاراپ مشاهده پروفایل آندری شیکوف
Jonathan Starup و Andrei Shikov

با شروع از AGP 9.2.0، R8 اکثر فراخوانی‌های Atomic*FieldUpdater را به انواع Unsafe که در عملیات رایج 2 تا 4 برابر بهتر عمل می‌کنند، بهینه می‌کند. این امر تأثیر زیادی بر کتابخانه kotlinx.atomicfu دارد که atomics را برای kotlinx.coroutines پیاده‌سازی می‌کند و راه‌اندازی و لغو coroutines را تا 2 برابر سریع‌تر می‌کند. برای بهره‌مندی از مزایا، AGP خود را به 9.2.0 یا بالاتر به‌روزرسانی کنید.

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

همانطور که تیم Compose شروع به بررسی عملکرد کرد، مشخص شد که کوروتین‌ها برای بسیاری از عملیاتی که خارج از ترکیب اتفاق می‌افتند، گلوگاه هستند. به عنوان مثال، ۸۰٪ از زمان صرف شده برای ایجاد و به‌روزرسانی Modifier.clickable صرف راه‌اندازی و لغو کوروتین‌های داخلی شد که به‌روزرسانی‌های InteractionSource را مدیریت می‌کردند. بر اساس این مشاهدات، بخش زیادی از کارهای اولیه عملکرد بر حذف کوروتین‌ها از مسیر پیش‌فرض و به تأخیر انداختن مقداردهی اولیه تا زمان لزوم متمرکز بود.

هزینه یک کوروتین

ساده‌ترین راه برای تحلیل رفتار داخلی یک تابع در اندروید، ثبت ردیابی متد Android Runtime (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 در واقع بیش از 10 سال است که روی JVM به خوبی بهینه شده است و ردیابی متدها ممکن است سربارهایی را ثبت کند که با بهینه‌سازی سطح VM کاملاً حذف می‌شوند: کامپایل‌های just-in-time (JIT) یا ahead-of-time (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 (ضمن اطمینان از کامپایل JIT بودن AtomicReferenceFieldUpdater#compareAndSet در حین گرم شدن سیستم)، نتایج زیر را در Pixel 5 (API 33) به دست می‌دهد:

 50.7 ns  atomicReference_compareAndSet
135   ns  atomicRef_compareAndSet

اندازه‌گیری‌ها این شکاف را تأیید می‌کنند، نسخه kotlinx.atomicfu به وضوح تقریباً ۲.۷ برابر کندتر است. این تأیید می‌کند که ART هیچ بهینه‌سازی پنهانی انجام نمی‌دهد و بررسی‌های دسترسی انعکاسی، سربار واقعی را در زمان اجرا اضافه می‌کنند.

با نگاهی به ردیابی متد اصلی، تنها کار معناداری که توسط AtomicReferenceFieldUpdater انجام می‌شود، فراخوانی داخلی Unsafe.getObjectVolatile است که در واقع عملیات اتمی زیربنایی را اجرا می‌کند. در بیشتر موارد، مقداردهی اولیه به‌روزرسانی استاتیک است و می‌توان ثابت کرد که همیشه بر اساس ساختار کلاس اطراف، صحیح است. بنابراین، می‌توان اکثر کاربردهای AtomicReferenceFieldUpdater را به صورت استاتیک تجزیه و تحلیل کرد و آنها را در حین کامپایل با یک نوع Unsafe داخلی جایگزین کرد. همچنین اتفاقاً زنجیره ابزار ساخت اندروید، کامپایلر بهینه‌سازی مخصوص به خود را دارد که می‌تواند دقیقاً همین کار را انجام دهد.

بهینه‌سازی با R8

کلاس‌های Atomic*FieldUpdater از کاربردهای ظریف، پویا و مبتنی بر بازتاب پشتیبانی می‌کنند، اما اغلب در الگوهای استاتیک واضح استفاده می‌شوند. این موضوع هم عملکرد پایه کند و هم نیاز به بهینه‌سازی را توضیح می‌دهد. R8 یک کامپایلر بهینه‌سازی کامل برنامه است و برای دیدن الگوهای ساده‌تر و کاهش سربار بررسی‌های ایمنی بازتابی بسیار مناسب است. R8 بایت‌کد JVM را پس از کامپایلر جاوا یا کاتلین دریافت می‌کند، اما برای سهولت در خوانایی، این مثال‌ها در سینتکس جاوا ارائه شده‌اند. به همین دلیل است که هیچ آرگومان نوعی برای 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 یک پوشش پیرامون یک فیلد offset و فراخوانی‌های Unsafe است. بهترین سناریو برای بهینه‌سازی، جایگزینی فیلد updater با یک فیلد offset و جایگزینی فراخوانی‌های updater با فراخوانی‌های Unsafe است.

بهینه‌سازی Atomic*FieldUpdater

بهینه‌سازی در سه بخش انجام می‌شود: ابزار دقیق، جایگزینی و پاکسازی.

ابزار دقیق

اولین قدم، معرفی فیلدهای offset در کنار فیلد updater است تا دسترسی مستقیم از طریق فراخوانی Unsafe تسهیل شود.

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

این فیلد از طریق reflection قابل دسترسی است و Unsafe برای استخراج offset فیلد در کلاس استفاده می‌شود. این کد در صورت صرف نظر کردن از اعتبارسنجی reflection، نشان‌دهنده‌ی اجزای داخلی Atomic*FieldUpdater است. در عوض، نوع نگهدارنده‌ی updater و نوع فیلد volatile به صورت استاتیک در کامپایلر ردیابی می‌شوند.

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

جایگزینی

در این مرحله از کامپایلر، پس از یک نقطه اتصال همزمانی مناسب، لیستی از فیلدهای به‌روزرسانی ابزاربندی شده داریم. این بدان معناست که می‌توانیم هر سایت فراخوانی را به صورت جداگانه و بر اساس چند شرط بهینه کنیم. یک فراخوانی نمونه را در نظر بگیرید:

updater.compareAndSet(holder, expectedValue, newValue);

شرایطی که Atomic*FieldUpdater نیاز دارد عبارتند از:

  • آیا updater از یک فیلد ابزار دقیق می‌آید؟ یعنی، آیا تحلیل استاتیک می‌تواند مقدار شیء را تا فیلد خوانده شده توسط یک به‌روزرسانی ابزار دقیق ردیابی کند؟
  • آیا holder همان کلاس است یا زیرکلاسی از نوع holder تعریف شده اولیه است؟
  • آیا newValue همان کلاس است یا یک زیرکلاس از نوع فیلد تعریف‌شده‌ی اولیه؟

اگر همه شرایط برقرار باشند، فراخوانی با فراخوانی Unsafe بدون هیچ یک از بررسی‌های بازتاب جایگزین می‌شود.

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

این فراخوانی جدید سریع‌تر و ساده‌تر است، اما از نظر نحوه‌ی مدیریت مقادیر تهی در updater و holder ، با فراخوانی اصلی متفاوت است. مگر اینکه به صورت ایستا رد شود، بررسی‌های تهی برای هر دو درج می‌شوند.

پاکسازی

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

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

در نهایت، مثال ساده‌ی به‌روزرسانی که در بالا نشان داده شده است، پس از بهینه‌سازی به این شکل در می‌آید:

نتایج

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

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

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

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

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

نوشته شده توسط:
ادامه مطلب