اخبار محصول

‫۱۸٪ همگردانی سریع‌تر، ۰٪ سازش

‫۸ دقیقه خواندن
مشاهده نمایه Santiago Aboy Solanes مشاهده نمایه چارلز مانگر
‫ Santiago Aboy Solanes و Vladimír Marko

تیم زمان اجرای Android (ART) زمان ترجمه را بدون به‌خطر انداختن کد ترجمه‌شده یا هرگونه پس‌رفت حافظه اوج ۱۸٪ کاهش داده است. این بهبود بخشی از ابتکار سال ۲۰۲۵ ما برای بهبود زمان ترجمه بدون فدا کردن استفاده از حافظه یا کیفیت کد ترجمه‌شده بود.

بهینه‌سازی سرعت زمان ترجمه برای ART بسیار مهم است. برای مثال، هنگام ترجمه درجا (JIT)، این امر مستقیماً بر کارایی برنامه‌ها و عملکرد کلی دستگاه تأثیر می‌گذارد. کامپایل‌های سریع‌تر زمان قبل‌از شروع بهینه‌سازی‌ها را کاهش می‌دهد و منجر به تجربه کاربری روان‌تر و واکنش‌گراتر می‌شود. علاوه‌براین، هم برای JIT و هم برای «پیش‌از موعد» (AOT)، بهبود سرعت زمان ترجمه به کاهش مصرف منابع درطول فرایند ترجمه منجر می‌شود که برای عمر باتری و دمای دستگاه، به‌ویژه در دستگاه‌های رده‌پایین، مفید است.

برخی‌از این بهبودهای سرعت زمان ترجمه در نسخه ژوئن ۲۰۲۵ Android عرضه شده است و بقیه در نسخه پایان سال Android دردسترس قرار خواهد گرفت. علاوه‌براین، همه کاربران Android در نسخه‌های ۱۲ و بالاتر واجدشرایط دریافت این بهبودها ازطریق به‌روزرسانی‌های اصلی هستند.

درحال بهینه‌سازی کردن مترجم بهینه‌ساز

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

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

یافتن بهینه‌سازی‌های احتمالی ارزشمند

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

 

ازآنجایی‌که منبعی که برای این بهبودها فدا می‌کردیم زمان توسعه ما بود، می‌خواستیم بتوانیم تا جایی که می‌توانیم سریع تکرار کنیم. این یعنی ما چند برنامه نمونه (ترکیبی از برنامه‌های شخص اول، برنامه‌های شخص ثالث، و خود سیستم‌عامل Android) را برای ساختن نمونه اولیه راهکارها انتخاب کردیم. بعداً با آزمایش‌های دستی و خودکارسازی‌شده گسترده تأیید کردیم که پیاده‌سازی نهایی ارزشش را داشته است.

 

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

تصویر.png

نمونه‌ای از نمودار شعله‌ای نمایه در pprof

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

یکی از این نماها، نمای «پایین به بالا» است که در آن می‌توانید ببینید کدام روش‌ها بیشترین زمان را می‌گیرند. در تصویر زیر، می‌توانیم روشی به‌نام Kill را ببینیم که بیش‌از ۱٪ زمان ترجمه را به خود اختصاص داده است. برخی‌از روش‌های برتر دیگر نیز بعداً در پست وبلاگ مورد بحث قرار خواهند گرفت.

تصویر.png

نمای پایین به بالای نمایه

در کامپایلر بهینه‌سازی ما، مرحله‌ای به‌نام «شماره‌گذاری مقدار سراسری» (GVN) وجود دارد. لازم نیست نگران عملکرد کلی آن باشید، اما بخش مربوطه این است که بدانید روشی به نام `Kill` دارد که برخی‌از گره‌ها را براساس فیلتر حذف می‌کند. این کار زمان‌بر است زیرا باید همه گره‌ها را تکرار کند و یکی‌یکی بررسی کند. متوجه شدیم که در برخی موارد ازقبل می‌دانیم که بررسی نادرست خواهد بود، صرف‌نظر از اینکه در آن نقطه چه گره‌هایی فعال باشند. در این موارد، می‌توانیم از تکرار به‌طور کامل صرف‌نظر کنیم، آن را از ۱٫۰۲۳٪ به حدود ۰٫۳٪ کاهش دهیم و زمان اجرای GVN را حدود ۱۵٪ بهبود دهیم.

اجرای بهینه‌سازی‌های ارزشمند

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

معمولاً در موردی مانند مورد `Kill` در بالا، نحوه تکرار گره‌ها را بررسی می‌کنیم و با انجام کارها به‌صورت موازی یا بهبود خود الگوریتم، آن را سریع‌تر انجام می‌دهیم. درواقع، این همان چیزی است که در ابتدا امتحان کردیم و تنها زمانی که نتوانستیم کاری انجام دهیم، لحظه‌ای «صبر کنید…» داشتیم و دیدیم که راه‌حل این است که (در برخی موارد) اصلاً تکرار نکنیم! هنگام انجام این نوع بهینه‌سازی‌ها، به‌راحتی ممکن است به‌دلیل توجه به جزئیات، از تصویر کلی غافل شوید.

در موارد دیگر، از چند تکنیک مختلف استفاده کردیم، ازجمله:

  • استفاده از روش‌های اکتشافی برای تصمیم‌گیری درباره اینکه آیا بهینه‌سازی نتایج ارزشمندی تولید نخواهد کرد و بنابراین می‌توان از آن صرف‌نظر کرد
  • استفاده از ساختارهای داده اضافی برای ذخیره داده‌های محاسبه‌شده در حافظه نهان
  • تغییر ساختارهای داده فعلی برای افزایش سرعت
  • محاسبه تنبل نتایج برای اجتناب از چرخه‌ها در برخی موارد
  • از انتزاع مناسب استفاده کنید - ویژگی‌های غیرضروری می‌تواند کد را کند کند
  • از دنبال کردن اشاره‌گر پرکاربرد در بارگیری‌های متعدد اجتناب کنید

چگونه بدانیم که بهینه‌سازی‌ها ارزش دنبال کردن دارند؟

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

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

  1. برآورد با سنجه‌هایی که قبلاً جمع‌آوری کرده‌اید، یا فقط یک احساس درونی
  2. برآورد کردن با نمونه اولیه سریع و ساده
  3. راه‌حلی را اجرا کنید.

فراموش نکنید که معایب راه‌حل خود را نیز برآورد کنید. برای مثال، اگر قرار است به ساختارهای داده اضافی تکیه کنید، چقدر حافظه می‌خواهید استفاده کنید؟

کاوش عمیق‌تر

بدون مقدمه، بیایید به برخی‌از تغییراتی که اعمال کرده‌ایم نگاهی بیندازیم.

تغییری برای بهینه‌سازی روشی به‌نام FindReferenceInfoOf اعمال کردیم. این روش برای پیدا کردن ورودی، جستجوی خطی بردار انجام می‌داد. ما آن ساختار داده را به‌روزرسانی کردیم تا با شناسه دستورالعمل نمایه شود تا FindReferenceInfoOf به‌جای O(n) برابر با O(1) باشد. همچنین، بردار را ازقبل اختصاص دادیم تا از تغییر اندازه جلوگیری شود. حافظه را کمی افزایش دادیم چون مجبور بودیم فیلد اضافه‌ای اضافه کنیم که تعداد ورودی‌های درج‌شده در بردار را می‌شمرد، اما این فداکاری کوچکی بود چون اوج حافظه افزایش نیافت. این کار سرعت فاز LoadStoreAnalysis ما را ۳۴ تا ۶۶ درصد افزایش داد که به‌نوبه خود باعث بهبود حدود ۰٫۵ تا ۱٫۸ درصدی زمان ترجمه می‌شود.

ما یک پیاده‌سازی سفارشی از HashSet داریم که در چندین مکان از آن استفاده می‌کنیم. ایجاد این ساختار داده زمان قابل‌توجهی می‌برد و ما دلیل آن را پیدا کردیم. سال‌ها پیش، این ساختار داده فقط در چند مکان که از HashSetهای بسیار بزرگ استفاده می‌کردند استفاده می‌شد و برای بهینه‌سازی در آن مکان‌ها اصلاح شده بود. بااین‌حال، امروزه در جهت مخالف با تنها چند ورودی و با طول عمر کوتاه استفاده می‌شود. این یعنی ما با ایجاد این HashSet بزرگ چرخه‌ها را هدر می‌دادیم اما فقط برای چند ورودی از آن استفاده می‌کردیم و سپس آن را دور می‌انداختیم. با این تغییر، زمان ترجمه را حدود ۱٫۳ تا ۲ درصد بهبود بخشیدیم. به‌عنوان یک مزیت اضافی، استفاده از حافظه حدود ۰٫۵ تا ۱ درصد کاهش یافت زیرا ما از ساختارهای داده بزرگ مانند قبل استفاده نمی‌کردیم.

با ارسال ساختارهای داده ازطریق ارجاع به لامبدا برای جلوگیری از کپی کردن آن‌ها، زمان ترجمه را حدود ۰٫۵ تا ۱ درصد بهبود دادیم. این چیزی بود که در بازبینی اصلی ازقلم افتاده بود و سال‌ها در پایگاه کد ما باقی مانده بود. به‌لطف نگاهی که به نمایه‌ها در pprof انداختیم متوجه شدیم که این روش‌ها ساختارهای داده زیادی را ایجاد و تخریب می‌کنند، که باعث شد آن‌ها را بررسی و بهینه‌سازی کنیم.

با ذخیره کردن مقادیر محاسبه‌شده در حافظه نهان، فازی را که خروجی ترجمه‌شده را می‌نویسد تسریع کردیم که این امر به بهبود حدود ۱٫۳ تا ۲٫۸ درصدی کل زمان ترجمه منجر شد. متأسفانه، ثبت اضافی بیش‌ازحد بود و آزمایش خودکار ما درباره پس‌رفت حافظه به ما هشدار داد. بعداً، نگاهی دوباره به همان کد انداختیم و نسخه جدیدی را پیاده‌سازی کردیم که نه‌تنها مشکل پس‌رفت حافظه را برطرف کرد، بلکه زمان ترجمه را نیز حدود ۰٫۵ تا ۱٫۸ درصد بهبود بخشید! در این تغییر دوم، مجبور شدیم نحوه عملکرد این مرحله را بازسازی و بازانگاری کنیم تا از یکی از دو ساختار داده خلاص شویم.

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

دو بررسی را از دسته «بررسی‌های نهایی» به دسته «اکتشافی» منتقل کردیم تا قبل‌از انجام هرگونه محاسبات زمان‌بر، تخمین بزنیم که آیا درون‌خطی‌سازی موفقیت‌آمیز خواهد بود یا نه. ازآنجایی‌که این یک تخمین است، کامل نیست، اما ما تأیید کردیم که اکتشافات جدید ما ۹۹٫۹٪ از آنچه قبلاً درون‌خطی شده بود را بدون تأثیر بر عملکرد پوشش می‌دهد. یکی از این اکتشافی‌های جدید درباره ثبت‌های DEX موردنیاز (~۰٫۲-۱٫۳٪ بهبود) و دیگری درباره تعداد دستورالعمل‌ها (~۲٪ بهبود) بود.

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

علاوه‌براین، کلاس BitVectorView در نوع ذخیره‌سازی زیربنایی قالب‌بندی شد (به‌جای اینکه همیشه از uint32_t به‌عنوان BitVector قدیمی استفاده شود). این کار به برخی‌از عملیات‌ها، برای مثال Union()‎، اجازه می‌دهد تا دو برابر بیت‌های بیشتری را باهم در پلاتفرم‌های ۶۴ بیتی پردازش کنند. نمونه‌های توابع تحت‌تأثیر قرارگرفته درمجموع بیش‌از ۱٪ هنگام کامپایل کردن سیستم‌عامل Android کاهش یافت. این کار در چندین تغییر انجام شد [۱، ۲، ۳، ۴، ۵، ۶]

اگر بخواهیم درباره همه بهینه‌سازی‌ها با جزئیات صحبت کنیم، باید تمام روز را اینجا بمانیم! اگر به بهینه‌سازی‌های بیشتری علاقه دارید، به برخی تغییرات دیگری که اعمال کرده‌ایم نگاهی بیندازید:

نتیجه‌گیری

تعهد ما به بهبود سرعت زمان ترجمه ART منجر به بهبودهای قابل‌توجهی شده است که Android را روان‌تر و کارآمدتر می‌کند و درعین‌حال به بهبود عمر باتری و دمای دستگاه کمک می‌کند. با شناسایی و اجرای دقیق بهینه‌سازی‌ها، نشان داده‌ایم که می‌توان بدون به‌خطر انداختن استفاده از حافظه یا کیفیت کد، به بهبودهای قابل‌توجهی در زمان ترجمه دست یافت.

سفر ما شامل نمایه‌سازی با ابزارهایی مانند pprof، تمایل به تکرار، و گاهی اوقات حتی رها کردن مسیرهای کم‌ثمر بود. تلاش‌های جمعی تیم ART نه‌تنها زمان ترجمه را به‌میزان قابل‌توجهی کاهش داده است، بلکه زمینه‌ساز پیشرفت‌های آینده نیز شده است.

همه این بهبودها در به‌روزرسانی Android پایان سال ۲۰۲۵ و برای Android 12 و بالاتر ازطریق به‌روزرسانی‌های خط اصلی دردسترس است. امیدواریم این بررسی عمیق در فرایند بهینه‌سازی ما، بینش‌های ارزشمندی درباره پیچیدگی‌ها و پاداش‌های مهندسی کامپایلر ارائه دهد!

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