کد برنامه حافظه است

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

حافظه فایل پشتیبان و صفحه بندی تقاضا

اندروید کد اجرایی را از .apk شما (مانند فایل‌های .oat یا .so ) با استفاده از mmap بارگذاری می‌کند. این بدان معناست که کد، فایل پشتیبان (file-backed ) است.

نکته مهم این است که اندروید از صفحه‌بندی تقاضا استفاده می‌کند. وقتی برنامه شما شروع می‌شود، هسته کل APK را بلافاصله در RAM بارگذاری نمی‌کند. در عوض، فقط فایل را در فضای آدرس مجازی فرآیند نگاشت می‌کند. با اجرای برنامه شما و پرش CPU به یک تابع جدید، یک "خطای صفحه" ایجاد می‌شود. هسته، رشته را متوقف می‌کند، آن صفحه کد ۴ کیلوبایتی خاص را از حافظه به RAM فیزیکی می‌خواند و اجرا را از سر می‌گیرد.

نموداری که صفحه‌بندی تقاضا را نشان می‌دهد، و نشان می‌دهد که صفحات مجازی فقط در صورت دسترسی به صفحات RAM فیزیکی نگاشت می‌شوند.

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

حذف صفحه و کاهش سرعت

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

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

هزینه خطای صفحه: اگرچه این هزینه بسته به سرعت ذخیره‌سازی دستگاه (UFS در مقابل eMMC) و وضعیت هسته بسیار متفاوت است، اما یک خطای صفحه بزرگ (خواندن ۴ کیلوبایت از حافظه) می‌تواند از ۰.۵ میلی‌ثانیه تا ۵ میلی‌ثانیه هزینه داشته باشد. اگر مسیر راه‌اندازی شما با ۵۰۰ صفحه مختلف از کد بهینه‌سازی نشده مواجه شود، به راحتی می‌توانید چند صد میلی‌ثانیه تأخیر ورودی/خروجی خالص را به زمان راه‌اندازی برنامه خود اضافه کنید.

بررسی حجم کد با Compiler Explorer

برای اینکه بفهمید کد جاوا یا کاتلین شما چگونه به کد ماشین بومی (و در نتیجه بایت‌های حافظه) تبدیل می‌شود، می‌توانید از Compiler Explorer استفاده کنید.

پشتیبانی از اندروید مستقیماً در Godbolt تعبیه شده است. این به شما امکان می‌دهد ببینید که چگونه بخش‌های مختلف زنجیره ابزار اندروید (D8، R8 و dex2oat) کد منبع شما را تغییر می‌دهند.

نحوه استفاده از کامپایلر اکسپلورر با اندروید

  1. به godbolt.org بروید.
  2. از منوی کشویی زبان (بالا سمت چپ)، اندروید جاوا یا اندروید کاتلین را انتخاب کنید.
  3. در منوی کشویی کامپایلر (بالا سمت راست پنجره کد)، می‌توانید از بین ابزارهای مختلف یکی را انتخاب کنید:
    • d8 : بایت‌کد دالویک ( .dex ) را نشان می‌دهد. این نزدیکترین نمایش به کد اصلی شماست و خواندن آن آسان‌تر است.
    • r8 : نشان می‌دهد که چگونه بهینه‌ساز R8 بایت‌کد شما را کوچک و بهینه می‌کند.
    • dex2oat : کد نهایی ماشین ARM64 که واقعاً روی دستگاه اجرا می‌شود را نشان می‌دهد. اینجاست که می‌توانید تأثیر واقعی حافظه (۴ بایت برای هر دستورالعمل) را ببینید. dex2oat می‌تواند ISA های مختلفی را هدف قرار دهد، اما ARM64 رایج‌ترین مورد برای تلفن‌های همراه است.
  4. برجسته‌سازی خروجی منبع : با نگه داشتن ماوس روی یک خط کد، دستورالعمل‌های بایت‌کد یا کد ماشین مربوطه برجسته می‌شوند و ردیابی تأثیر دستورات خاص را آسان می‌کنند.
  5. خط لوله بهینه‌سازی : در نمای جداسازی قطعات، می‌توانید روی «افزودن جدید...» کلیک کنید -> «انتخاب خط لوله» . این به شما امکان می‌دهد مراحل داخلی کامپایلر را مشاهده کنید. می‌توانید بررسی کنید که چگونه نمایش داخلی (IR) در هر مرحله (مثلاً بین مراحل «درون‌ریز (قبل)» و «درون‌ریز (بعد)») قبل از اینکه به کد ماشین ARM64 نهایی تبدیل شود، تبدیل می‌شود.

تصویری از رابط کاربری کامپایلر اکسپلورر که یک برنامه نمونه و خط لوله جداسازی و بهینه‌سازی خروجی dex2oat آن را به همراه مرحله inlining نشان می‌دهد.

چرا این برای حافظه مهم است؟

هر دستورالعملی که در خروجی dex2oat مشاهده می‌کنید و ARM64 ISA را هدف قرار می‌دهد، ۴ بایت از فایل اجرایی برنامه شما ( .odex یا .oat ) را اشغال می‌کند.

سعی کنید کدی را وارد کنید که از ویژگی‌های زبان‌های مختلف استفاده می‌کند و خروجی کامپایلر را بررسی کنید:

  • دسترسی به آرایه در مقابل تکرارکننده‌های لیست :
    • یک حلقه آرایه ساده روی int[] ممکن است به حدود ۱۰ دستورالعمل (حدود ۴۰ بایت) کامپایل شود.
    • یک حلقه foreach روی یک List به طور ضمنی از یک Iterator استفاده می‌کند. این می‌تواند به دلیل فراخوانی‌های متد اضافی ( hasNext() ، next() ) و تخصیص خود شیء iterator، منجر به 30 تا 40 دستورالعمل (حدود 160 بایت) شود.
    • بهینه‌سازی R8 : تحت شرایط مناسب (مثلاً وقتی ثابت شود که List یک ArrayList است)، بهینه‌ساز R8 می‌تواند یک حلقه foreach را به یک حلقه ساده با اندیس تبدیل کند، سربار تکرارکننده را از بین ببرد و هم اندازه کد و هم میزان از دست رفتن حافظه زمان اجرا را کاهش دهد.
  • فراخوانی‌های متد مجازی : شامل بارگذاری کلاس شیء، یافتن متد در vtable و سپس شاخه‌بندی است. این کار معمولاً ۴-۵ دستورالعمل (حدود ۲۰ بایت) طول می‌کشد.
  • فراخوانی‌های مستقیم/استاتیک : اغلب به یک دستورالعمل bl (شاخه با پیوند) (۴ بایت) تبدیل می‌شوند.
  • کاتلین لامبدا : می‌تواند کلاس‌های ناشناس کامل و متدهای پل اضافی تولید کند و صدها بایت کد و سربار فراداده را برای یک بلوک تابعی ساده اضافه کند.

با استفاده از Compiler Explorer، می‌توانید ببینید که چگونه ویژگی‌های پیچیده زبان (مانند لامبداهای کاتلین، APIهای جریانی یا استفاده زیاد از ژنریک‌ها) بر حجم نهایی کامپایل شده برنامه شما تأثیر می‌گذارند و چگونه بهینه‌سازهایی مانند R8 می‌توانند در برخی موارد هزینه انتزاع‌های زبان را خنثی کنند. این ابزار می‌تواند به شما در طراحی و پیاده‌سازی یک برنامه کمک کند تا معاملات آگاهانه‌ای انجام دهید.

به طور کلی، پیچیدگی بیشتر در کد برنامه شما منجر به استفاده بیشتر از حافظه می‌شود. برعکس، کد ساده‌تر - یا کدی که توسط R8 ساده‌سازی شده است - منجر به نمایش کوچکتری به عنوان دستورالعمل‌های CPU و بایت‌های کمتری در فضای ذخیره‌سازی و RAM می‌شود.

اندازه‌گیری تأثیر کد با meminfo و showmap

شما می‌توانید از ابزارهای استاندارد حافظه اندروید برای مشاهده میزان حافظه مصرفی کد برنامه خود استفاده کنید.

dumpsys meminfo

وقتی دستور adb shell dumpsys meminfo <package> را اجرا می‌کنید، دسته‌بندی Code در بخش App Summary یک نمای سطح بالا از حافظه مرتبط با کد ارائه می‌دهد:

 App Summary
                       Pss(KB)
                        ------
           Java Heap:     3244
         Native Heap:     5412
                Code:    24512  # <--- Sum of .so, .dex, .oat, .art, etc.

showmap

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

adb shell showmap $(pidof <package>) | grep -E "\.oat|\.odex|\.dex|\.apk"

ورودی‌های کد کامپایل‌شده‌ی برنامه‌ی خود را مشاهده خواهید کرد:

   size      RSS      PSS    clean    dirty    clean    dirty     swap  swapPSS object
------- -------- -------- -------- -------- -------- -------- -------- -------- ----------------
  12288     8192     8192     8192        0        0        0        0        0 /data/app/.../base.odex

کد مرده و R8

از آنجا که هر متد اجرا شده حافظه را اشغال می‌کند، داشتن یک برنامه "حجم بالا" با مقداردهی‌های اولیه غیرضروری یا کتابخانه‌های بلااستفاده می‌تواند به شدت بر عملکرد راه‌اندازی و استفاده از حافظه پایه تأثیر بگذارد.

به همین دلیل ابزارهایی مانند R8 (ProGuard) بسیار مهم هستند. R8 بایت‌کد برنامه شما را تجزیه و تحلیل می‌کند و هر کلاس یا متدی را که هرگز فراخوانی نمی‌شود حذف می‌کند ("حذف کد مرده").

ورزش عملی: هزینه نفخ

برای نشان دادن تأثیر اندازه کد، آزمایشی را در نظر بگیرید که دو نسخه از یک برنامه کاربردی حاوی ۳۰۰ کلاس تولید شده (هر کدام با ۵۰۰ متد) را با هم مقایسه می‌کند:

  • CodeBloat (بهینه‌نشده) : نسخه‌ی استاندارد و بهینه‌نشده که شامل تمام کلاس‌های تولید شده و رشته‌های منحصر به فرد است.
  • CodeBloatOptimized : همان کد منبع، اما با فعال بودن قابلیت کوچک‌سازی R8 کامپایل شده است.

۱. تدوین پیش از موعد (AOT)

برای به حداکثر رساندن تأثیر حافظه‌ی پشتیبان‌گیری‌شده از فایل، از ابزار cmd package compile برای کامپایل زودهنگام (AOT) برنامه‌ها در فایل‌های .oat استفاده خواهیم کرد.

adb shell cmd package compile -m speed -f com.android.codebloat
adb shell cmd package compile -m speed -f com.android.codebloat.optimized

لطفاً توجه داشته باشید که این یک مثال مصنوعی است. معمولاً برنامه‌ها از حالت کامپایل speed-profile استفاده می‌کنند (به ادامه مطلب مراجعه کنید).

۲. راه‌اندازی و مقایسه

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

برنامه بهینه نشده را اجرا کنید:

adb shell am force-stop com.android.codebloat
# Drop page cache to ensure the start is truly cold
adb shell "echo 3 > /proc/sys/vm/drop_caches"
adb shell am start -W -n com.android.codebloat/.MainActivity
sleep 5 # Wait for the background thread to load classes
adb shell dumpsys meminfo -s com.android.codebloat

حالا همین کار را برای برنامه بهینه شده انجام دهید:

adb shell am force-stop com.android.codebloat.optimized
# Drop page cache to ensure the start is truly cold
adb shell "echo 3 > /proc/sys/vm/drop_caches"
adb shell am start -W -n com.android.codebloat.optimized/com.android.codebloat.MainActivity
sleep 5
adb shell dumpsys meminfo -s com.android.codebloat.optimized
نتایج

اگر به ردیف کد در بخش App Summary نگاه کنید، تفاوت بزرگی را مشاهده خواهید کرد:

  • Code بهینه‌سازی نشده : حدود ۳۰،۰۰۰ کیلوبایت (۳۰ مگابایت)
  • Code بهینه شده : حدود ۲۰۰۰ کیلوبایت (۲ مگابایت)

از آنجا که R8 تشخیص داد که ۵۰۰ متد درون آن کلاس‌ها هرگز کار مفیدی انجام نمی‌دهند (متد doSomething() فقط method0() را فراخوانی می‌کند و نتایج نادیده گرفته می‌شوند)، تقریباً تمام کد تولید شده مصنوعی را از APK نهایی حذف کرد.

۳. تاثیر را در Perfetto ببینید

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

در یک شروع سرد تعاملی، سیستم کدهای mmap() و madvise() و سایر داده‌های لازم از این فایل‌ها را برای بارگذاری و اجرای برنامه، استخراج می‌کند. مقدار بعد از "size=" در برش‌های madvising نشان می‌دهد که چه مقدار داده باید بارگذاری شود. این پیش‌واکشی کد برنامه برای تسریع راه‌اندازی برنامه انجام می‌شود.

از این مقایسه می‌توانیم ببینیم که میزان کد برنامه‌ای که باید از حافظه به رم بارگذاری می‌شد، در مورد برنامه حجیم بسیار بیشتر بود، که منجر به مدت زمان طولانی‌تری شد که به شروع کندتر برنامه کمک کرد. علاوه بر این، ردپای شروع برنامه حجیم، برش‌هایی را برای بارگذاری فایل‌های DEX ثانویه ( classes2.dex ، classes3.dex ) نشان می‌دهد که برنامه حجیم مجبور به "ریختن" در آنها شده است زیرا در یک فایل DEX جا نمی‌شد.

در مقایسه (شروع سرد در پیکسل 10a)
متریک بهینه‌سازی نشده (CodeBloat) بهینه شده (CodeBloatOptimized)
اندازه madvise base.odex حدود ۷.۹ مگابایت (۲.۰ میلی‌ثانیه) حدود ۱۶ کیلوبایت (۰.۰۰۳ میلی‌ثانیه)
حجم فایل madvise base.apk تقریباً ۲.۴ مگابایت (۲.۴ میلی‌ثانیه) حدود ۴ کیلوبایت (۰.۰۰۱ میلی‌ثانیه)
classes2.dex اندازه madvise تقریباً ۷.۳ مگابایت (۸.۶ میلی‌ثانیه) ناموجود
classes3.dex اندازه madvise حدود ۷.۳ مگابایت (۸.۰ میلی‌ثانیه) ناموجود
کل مدت زمان madvising حدود ۲۱ میلی‌ثانیه ~0.004 میلی‌ثانیه
عملکرد بارگذاری برنامه بهینه نشده

تصویری از رابط کاربری Perfetto که فرآیند com.android.codebloat را به همراه برش‌های madvising برای فایل‌های DEX اولیه و ثانویه نشان می‌دهد.

عملکرد بارگذاری برنامه بهینه شده است

تصویری از رابط کاربری Perfetto که فرآیند com.android.codebloat.optimized را با یک برش کوچک madvising نشان می‌دهد.

تأثیر حجم زیاد کد بسته به اندازه برنامه، ویژگی‌های دستگاه کاربر و بار سیستم متفاوت است.

PerfettoSQL برای تحلیل بارگذاری

می‌توانید از کوئری‌های زیر برای استخراج این معیارها از ردپاهای خود استفاده کنید.

۱. مدت زمان شروع برنامه

این مدت زمان از زمان اجرای اکتیویتی یک برنامه تا زمانی که اکتیویتی اولین فریم را رسم کند، نشان می‌دهد.

INCLUDE PERFETTO MODULE android.startup.startups;

SELECT package, dur, startup_type
FROM android_startups
WHERE package LIKE 'com.android.codebloat%';

ببینید: حالت‌های مختلف راه‌اندازی برنامه را درک کنید

مدت زمان شروع به کار یک برنامه به عوامل زیادی غیر از مواردی که در این راهنما پوشش داده شده است، حساس است!

۲. اندازه‌ها و مدت زمان‌های madvising را استخراج کنید

این پرس‌وجو روی بخش madvising که در بالا دیدیم تمرکز می‌کند.

INCLUDE PERFETTO MODULE slices.with_context;

SELECT
  name,
  dur/1e6 AS dur_ms
FROM thread_slice
WHERE process_name LIKE 'com.android.codebloat%'
  AND name LIKE 'madvising %';
۳. تفکیک وضعیت نخ اصلی (مدت زمان کل به ازای هر وضعیت)

این کوئری نشان می‌دهد که ترد اصلی برنامه چقدر زمان را در حالت‌های مختلف صرف کرده است.

SELECT
  p.name AS process_name,
  state,
  sum(dur)/1e6 AS total_dur_ms
FROM thread_state ts
JOIN thread t USING (utid)
JOIN process p USING (upid)
WHERE p.name LIKE 'com.android.codebloat%'
  AND t.is_main_thread = 1
GROUP BY p.name, state;

شما می‌توانید کوئری را طوری اصلاح کنید که فقط حالت‌های نخ اصلی را در طول مدت زمان راه‌اندازی برنامه بررسی کند.

INCLUDE PERFETTO MODULE android.startup.startups;

SELECT
  p.name AS process_name,
  ts.state,
  -- Calculate only the duration that falls within the startup window
  SUM(
    MAX(0,
      MIN(ts.ts + ts.dur, s.ts + s.dur) - MAX(ts.ts, s.ts)
    )
  ) / 1e6 AS startup_dur_ms
FROM thread_state ts
JOIN thread t USING (utid)
JOIN process p USING (upid)
-- Join on the package name to align thread states with the correct startup
JOIN android_startups s ON s.package = p.name
WHERE p.name LIKE 'com.android.codebloat%'
  AND t.is_main_thread = 1
  -- Only select thread states that overlap with the startup interval
  AND ts.ts + ts.dur > s.ts
  AND ts.ts < s.ts + s.dur
GROUP BY 1, 2
ORDER BY startup_dur_ms DESC;

این می‌تواند برخی از مشکلات جالب را آشکار کند، برای مثال:

  • زمان زیادی صرف اجرای برنامه شده است (R) اما اجرا نمی‌شود : این نشان می‌دهد که راه‌اندازی برنامه به دلیل درگیری CPU به تأخیر افتاده است، یعنی رشته اصلی برنامه نمی‌تواند اجرا شود زیرا رشته‌های دیگر (احتمالاً از برنامه‌های دیگر) CPU را اشغال کرده‌اند.
  • زمان زیاد صرف شده در حالت خواب وقفه‌پذیر (D) : این معمولاً نشان‌دهنده‌ی ورودی/خروجی کند یا فشار حافظه است که باعث توقف راه‌اندازی برنامه می‌شود.
  • زمان بالای خواب (S) : این بدان معناست که نخ اصلی منتظر انجام کار توسط نخ‌های دیگر بوده است. گاهی اوقات این نشان دهنده‌ی قفل شدن مسیر راه‌اندازی برنامه است (یعنی نخ اصلی روی یک منبع انحصاری که توسط نخ دیگری در برنامه اشغال شده بود، مسدود شده بود).
۴. حداکثر حافظه پشتیبانی‌شده برای فایل (فایل RSS)

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

SELECT
  p.name AS process_name,
  max(c.value)/1024.0/1024.0 AS max_rss_file_mb
FROM counter c
JOIN process_counter_track t ON c.track_id = t.id
JOIN process p USING (upid)
WHERE p.name LIKE 'com.android.codebloat%'
  AND t.name = 'mem.rss.file'
GROUP BY p.name;

حالت‌های کامپایل ART و حافظه

زمان اجرای اندروید (ART) می‌تواند کد برنامه شما را در یکی از چندین حالت مختلف، که به عنوان فیلترهای کامپایلر نیز شناخته می‌شوند، کامپایل کند. فیلتر کامپایلر انتخاب شده تأثیر مستقیمی بر میزان حافظه اشغال شده توسط برنامه شما دارد.

  • verify : ART فقط تأیید بایت‌کد را انجام می‌دهد. هیچ کامپایل AOT انجام نمی‌شود. کد از طریق مفسر اجرا می‌شود یا در زمان اجرا توسط کامپایلر JIT کامپایل می‌شود.
    • تأثیر حافظه : کمترین اندازه روی دیسک. استفاده از حافظه کد بومی به JIT Cache (حافظه کثیف ناشناس) منتقل می‌شود.
  • speed : ART کامپایل کامل AOT از تمام متدها را انجام می‌دهد.
    • تأثیر بر حافظه : بزرگترین اندازه .odex . استفاده از حافظه پشتیبان‌گیری‌شده (تمیز) توسط فایل را به حداکثر می‌رساند.
  • speed-profile : ART فقط متدهایی را کامپایل می‌کند که در پروفایل JIT به عنوان "hot" علامت‌گذاری شده‌اند.
    • تأثیر حافظه : رویکرد متعادل. فقط مهم‌ترین کدها به صورت AOT کامپایل می‌شوند.

رایج‌ترین فیلتر، speed-profile است که هنگام نصب برنامه‌های کاربر استفاده می‌شود. این فیلتر در ویژگی‌های سیستم pm.dexopt.install و pm.dexopt.bg-dexopt پیکربندی شده است و معمولاً در build/make/target/product/runtime_libart.mk تنظیم می‌شود.

برخی از برنامه‌های سیستمی از کامپایل speed استفاده می‌کنند و همچنین در زمان ساخت تصویر سیستم کامپایل می‌شوند. verify معمولاً فقط در موارد استفاده توسعه استفاده می‌شود.

مورد استفاده فیلتر کامپایلر معمولی
توسعه verify
تصویر سیستم speed
برنامه‌های کاربر speed-profile

تمرین عملی: حالت‌های کامپایل و حافظه

می‌توانیم از برنامه CodeBloat برای مشاهده‌ی چگونگی تأثیر این فیلترها بر حافظه استفاده کنیم. برای بازتولید این اندازه‌گیری‌ها:

  1. برنامه را مجبور کنید تا در حالت هدف، دوباره کامپایل شود.
  2. برنامه را مجبور به توقف اجباری و شروع سرد کنید.
  3. منتظر بمانید تا رشته پس‌زمینه کار خود را با کلاس‌ها تمام کند (logcat را تماشا کنید یا ۵ ثانیه صبر کنید).
  4. دستور adb shell dumpsys meminfo com.android.codebloat را اجرا کنید.

حالت: verify (بدون AOT)

adb shell cmd package compile -m verify -f com.android.codebloat
adb shell am force-stop com.android.codebloat
adb shell am start -W -n com.android.codebloat/.MainActivity
sleep 5
adb shell dumpsys meminfo com.android.codebloat

در حالت verify ، خلاصه برنامه موارد زیر را نشان می‌دهد: * کد PSS : حدود ۸۰۰۰ کیلوبایت * دالویک سایر (JIT) : حدود ۲۵۰۰۰ کیلوبایت

از آنجا که هیچ کدی به صورت خودکار کامپایل نمی‌شود، زمان اجرا باید متدهای داغ را در JIT Cache کامپایل کند، که به صورت حافظه کثیف ناشناس (Dirty anonymous memory ) (به Dalvik Other ) نمایش داده می‌شود.

حالت: speed (AOT کامل)

adb shell cmd package compile -m speed -f com.android.codebloat
adb shell am force-stop com.android.codebloat
adb shell am start -W -n com.android.codebloat/.MainActivity
sleep 5
adb shell dumpsys meminfo com.android.codebloat

در حالت speed ، نتایج به طرز چشمگیری تغییر می‌کنند: * کد PSS : حدود ۲۴۰۰۰ کیلوبایت * دالویک سایر (JIT) : حدود ۵۰۰۰ کیلوبایت

کد برنامه اکنون از فایل .odex به عنوان حافظه‌ی خالیِ پشتیبان‌گیری‌شده از فایل نگاشت می‌شود. این امر فشار بر حافظه‌ی نهان JIT را کاهش می‌دهد و باعث می‌شود حافظه به جای اینکه به عنوان رم کثیف "گیر" کند، تحت فشار قابل تخلیه باشد.

حالت: speed-profile (AOT انتخابی)

برنامه‌های مدرن ممکن است یک پروفایل پایه baseline.prof داشته باشند. ART از این برای کامپایل انتخابی فقط کد مورد نیاز برای راه‌اندازی سریع و کارآمد در حافظه استفاده می‌کند.

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

تولید و استفاده از پروفایل‌های روی دستگاه

برای مشاهده‌ی تأثیر speed-profile ، می‌توانید پروفایل خودتان را روی دستگاه ایجاد کنید:

  1. تنظیم مجدد و شروع :

    adb shell am force-stop com.android.codebloat
    
  2. تعامل : برنامه را اجرا کنید و اجازه دهید مراحل راه‌اندازی خود را طی کند.

  3. مشخصات تخلیه :

    adb shell kill -s SIGUSR1 $(pidof com.android.codebloat)
    

    (این کار برنامه را مجبور می‌کند تا پروفایل فعلی خود را روی دیسک بنویسد).

  4. نصب پروفایل :

    adb shell cp /data/misc/profiles/cur/0/com.android.codebloat/primary.prof \
    /data/misc/profiles/ref/com.android.codebloat/primary.prof
    
  5. کامپایل کردن :

    adb shell cmd package compile -m speed-profile -f com.android.codebloat
    

وقتی دوباره اجرا کنید، تعادل را مشاهده خواهید کرد: کد PSS کمتر از speed خواهد بود (مثلاً حدود ۱۶۰۰۰ کیلوبایت) زیرا فقط متدهای راه‌اندازی «داغ» کامپایل شده‌اند و بقیه فقط در صورتی که واقعاً استفاده شوند، توسط مفسر یا JIT مدیریت می‌شوند.

ببینید:

نگاهی عمیق به کد کامپایل شده

اگر می‌خواهید دقیقاً ببینید ART چه دستورالعمل‌هایی تولید می‌کند، به art/DISASSEMBLY_GUIDE.md مراجعه کنید.

این دستورالعمل‌های دقیقی در مورد استفاده ارائه می‌دهد:

  • oatdump : برای دیدن دستورالعمل‌های ARM64 درون یک فایل .odex موجود.
  • dex2oat : برای شبیه‌سازی کامپایل با پرچم‌های اشکال‌زدایی طولانی.

تمرین: درون‌خطی کردن کد

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

در برنامه CodeBloat ما، متد doSomething() در هر کلاس تولید شده، به سادگی method0() را فراخوانی می‌کند. وقتی در حالت speed کامپایل می‌شود، کامپایلر بهینه‌ساز ART احتمالاً method0() را در doSomething() درون‌خطی می‌کند.

تمرین: با استفاده از oatdump روی دستگاه خود، این موضوع را تأیید کنید:

# 1. Find the path to the application's APK and compiled .odex file
adb shell pm path com.android.codebloat
# Output: package:/data/app/~~.../base.apk

adb shell "dumpsys package com.android.codebloat | grep 'location is' | head -n 1"
# Example output: [location is /data/app/~~.../oat/arm64/base.odex]

# 2. Run oatdump (substituting the correct path to base.odex)
adb shell oatdump --oat-file=/data/app/~~.../oat/arm64/base.odex \
                  --class-filter=com.android.codebloat.GeneratedClass0

در خروجی به دنبال متد doSomething بگردید. اگر به صورت inline باشد، به جای یک دستورالعمل bl method0 را هدف قرار می‌دهد، دستورالعمل‌هایی برای بارگذاری ثابت رشته‌ای طولانی مستقیماً درون doSomething خواهید دید.

تجسم بهینه‌سازی (CFG)

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

  1. اجرای dex2oat با پرچم‌های dump : از پرچم --verbose-methods برای محدود کردن خروجی به متدهای خاص استفاده کنید؛ در غیر این صورت، فایل .cfg برای یک برنامه بزرگ می‌تواند تا چندین گیگابایت افزایش یابد.

    # Substitution of actual paths required:
    adb shell dex2oat64 --dex-file=/data/app/~~.../base.apk \
                        --oat-file=/data/local/tmp/dump.odex \
                        --compiler-filter=speed \
                        --dump-cfg=/data/local/tmp/codebloat.cfg \
                        --verbose-methods=doSomething
    
  2. کشیدن و مشاهده : فایل .cfg را به ایستگاه کاری خود بکشید و آن را با IR Hydra باز کنید.

  3. یافتن درون‌خطی : در IR Hydra، مصنوعات کامپایل را بارگذاری کنید و doSomething را جستجو کنید. نمایش قبل و بعد از Inliner pass را مقایسه کنید. خواهید دید که نمودار با ادغام دستورالعمل‌های method0 در فراخوانی‌کننده، گسترش می‌یابد.

روش دیگر، استفاده از ابزار Opt Pipeline در Compiler Explorer (همانطور که در بخش بالا توضیح داده شد) و وارد کردن کد مشابه برای مشاهده تبدیل مشابهی است که در مسیر Inliner انجام می‌شود.

تمرین: میدان‌های فرار و موانع حافظه

در برنامه MemoryLab ، فیلد mGarbageSink به عنوان volatile علامت‌گذاری شده است. این تضمین می‌کند که کامپایلر تخصیص‌های زباله ما را بهینه نمی‌کند.

public volatile byte[] mGarbageSink;

در دمونتاژ ARM64، خواهید دید که هر ذخیره در این فیلد با یک مانع حافظه ( dmb ish ) یا استفاده از دستورالعمل‌های Load-Acquire/Store-Release ( ldar / stlr ) همراه است. این امر قابلیت مشاهده نخ را تضمین می‌کند، اما چند دستورالعمل اضافی به هر دسترسی اضافه می‌کند و اندازه کد را در مقایسه با یک فیلد معمولی کمی افزایش می‌دهد.

تمرین: دسترسی‌های فیلد و موانع حافظه مرتبط را در disassemble پیدا کنید.

تمرین: بررسی‌های تعلیق ضمنی

اگر یک حلقه، مانند آنچه در generateAllocationChurn وجود دارد، را جدا کنید، متوجه یک دستورالعمل عجیب در انتهای بدنه حلقه خواهید شد:

ldr x21, [x21]

این یک بررسی تعلیق ضمنی است. ART از این استفاده می‌کند تا به Garbage Collector اجازه دهد تا نخ‌ها را به طور ایمن متوقف کند. ثبات x21 معمولاً به خودش اشاره می‌کند. وقتی GC نیاز به تعلیق نخ دارد، آن مکان حافظه را "مسموم" می‌کند. دفعه بعد که نخ آن ldr را اجرا می‌کند، یک خطا ایجاد می‌شود که زمان اجرا آن را دریافت کرده و برای انتقال نخ به حالت تعلیق استفاده می‌کند.

این الگو در هر حلقه و در ابتدای هر متد تکرار می‌شود و به حجم کل کد برنامه شما می‌افزاید.

تمرین: تمام بررسی‌های ضمنیِ تعلیق را در disassembly متد پیدا کنید و سعی کنید آنها را با کد منبع اصلی مرتبط کنید.


← نمای وب | ↑ بالا | موضوعات →