קוד האפליקציה הוא זיכרון

הקוד שאתם כותבים הוא בעצמו סוג של שימוש בזיכרון. כל מחלקה, שיטה וקבוע מחרוזת באפליקציה צריכים להיטען ל-RAM כשהם מופעלים. ככל שבסיס הקוד של האפליקציה גדול יותר, כך היא תצרוך יותר זיכרון רק כדי להתקיים.

זיכרון עם גיבוי קבצים והחלפת דפים לפי דרישה

מערכת Android טוענת קוד הפעלה מה-.apk (כמו קבצים בפורמט .oat או .so) באמצעות mmap. המשמעות היא שהקוד מגובה בקובץ.

חשוב לדעת שב-Android נעשה שימוש בהעברת דפים לפי דרישה. כשהאפליקציה מופעלת, ליבת מערכת ההפעלה לא טוענת את כל קובץ ה-APK לזיכרון ה-RAM באופן מיידי. במקום זאת, הוא ממפה רק את הקובץ למרחב הכתובות הווירטואלי של התהליך. בזמן שהאפליקציה פועלת, והמעבד עובר לפונקציה חדשה, מופעלת שגיאת דף. הליבה (kernel) משהה את השרשור, קוראת את דף הקוד הספציפי של 4KB מהאחסון ל-RAM הפיזי, וממשיכה את ההפעלה.

דיאגרמה שממחישה החלפת דפים לפי דרישה, שבה דפים וירטואליים ממופים לדפים פיזיים ב-RAM רק כשניגשים אליהם

המשמעות היא שקוד שאורזים אבל אף פעם לא מריצים לא משתמש בזיכרון פיזי עבור דפי הקוד עצמם. עם זאת, ספריות שלא נעשה בהן שימוש עדיין מגדילות את הגודל הכולל של קובץ ה-APK, ויכולות להגדיל באופן משמעותי את הזיכרון שמשמש את המטא-נתונים הפנימיים של המערכת (כמו אינדקסים של DEX ותיאורי מחלקות), שצריך לקרוא אותם כדי לדעת שהקוד קיים. בנוסף, ספריות רבות מכילות מאתחלים סטטיים או שהן מושפעות ממסגרות של הזרקת תלות במהלך הפעלת האפליקציה, ולכן הן נטענות ל-RAM בכל מקרה.

הוצאה של דפים מהזיכרון והאטה

מכיוון שתמיד אפשר לקרוא מחדש את הזיכרון שמוגבה בקובץ מהאחסון, ליבת המערכת מחשיבה את הדפים האלה כ "נקיים". כשהמערכת חווה עומס על הזיכרון, הליבה תפנה (תשליך) את דפי הקוד הנקיים האלה מזיכרון ה-RAM כדי לפנות מקום לדברים אחרים.

אם האפליקציה תצטרך להריץ את הקוד הזה שוב, תהיה שגיאה במעבד והליבה תצטרך לקרוא מחדש את הדף מהאחסון. ככל שהאפליקציה מכילה יותר קוד, כך היא פגיעה יותר להוצאת הקוד שלה. כשמשתמש חוזר לאפליקציה המנופחת אחרי שימוש באפליקציות אחרות, הוא יחווה תקלות אקראיות והאטה, כי המעבד נתקע כל הזמן בהמתנה להחזרת קוד מהאחסון.

העלות של שגיאת דף: העלות משתנה מאוד בהתאם למהירות האחסון של המכשיר (UFS לעומת eMMC) ולמצב הליבה, אבל שגיאת דף משמעותית (קריאה של 4KB מהאחסון) יכולה לעלות בין 0.5ms ל-5ms. אם נתיב ההפעלה שלכם כולל 500 דפים שונים של קוד לא מותאם, יכול להיות שזמן ההפעלה של האפליקציה יתארך בכמה מאות אלפיות השנייה של חביון קלט/פלט נטו.

בדיקת גודל הקוד באמצעות Compiler Explorer

כדי להבין איך קוד Java או Kotlin מתורגם לשפת מכונה מקורית (ובכך לבייטים בזיכרון), אפשר להשתמש ב-Compiler Explorer.

התמיכה ב-Android מובנית ישירות ב-Godbolt. כך תוכלו לראות איך חלקים שונים בשרשרת הכלים של Android‏ (D8,‏ R8 ו-dex2oat) משנים את קוד המקור.

איך משתמשים ב-Compiler Explorer עם Android

  1. עוברים אל godbolt.org.
  2. בוחרים באפשרות Android Java או Android Kotlin בתפריט הנפתח של השפה (בפינה הימנית העליונה).
  3. בתפריט הנפתח של הקומפיילר (בפינה השמאלית העליונה של חלונית הקוד), אפשר לבחור בין הכלים הבאים:
    • d8: מוצג קוד בייט של Dalvik ‏ (.dex). זו ההצגה הכי קרובה לקוד המקורי, והיא קלה יותר לקריאה.
    • r8: מראה איך כלי האופטימיזציה של R8 מצמצם ומבצע אופטימיזציה של קוד הבייט.
    • dex2oat: מציג את קוד המכונה הסופי של ARM64 שמופעל בפועל במכשיר. כאן אפשר לראות את ההשפעה האמיתית על הזיכרון (4 בייט לכל הוראה). ‫dex2oat יכול לטרגט ISA שונים, אבל ARM64 הוא הנפוץ ביותר בטלפונים ניידים.
  4. הדגשת קוד המקור<>הפלט: כשמעבירים את העכבר מעל שורת קוד, מודגשות ההוראות המתאימות של קוד הביניים או קוד המכונה, וכך קל לעקוב אחרי ההשפעה של הצהרות ספציפיות.
  5. צינור אופטימיזציה: בתצוגת הפירוק, אפשר ללחוץ על הוספה של חדש... ‫-> Opt Pipeline. כך תוכלו לראות את השלבים הפנימיים שהקומפיילר מבצע. אפשר לבדוק איך הייצוג הפנימי (IR) משתנה בכל שלב (למשל, בין השלבים Inliner (before)‎ ו-Inliner (after)) לפני שהוא מומר לקוד מכונה סופי של ARM64.

צילום מסך של ממשק המשתמש של Compiler Explorer שבו מוצגת תוכנית לדוגמה, פירוק הפלט של dex2oat וצינור האופטימיזציה עם שלב ההטמעה

למה זה חשוב לזיכרון

כל הוראה שמוצגת בפלט dex2oat של טירגוט ל-ARM64 ISA תופסת 4 בייט בקובץ ההפעלה של האפליקציה (.odex או .oat).

כדאי לנסות להזין קוד שמשתמש בתכונות שונות של השפה וללמוד את הפלט של הקומפיילר:

  • גישה למערך לעומת איטרטורים של רשימות:
    • לולאת מערך פשוטה מעל int[] עשויה להידור לכ-10 הוראות (כ-40 בייט).
    • לולאת foreach מעל List משתמשת באופן מרומז ב-Iterator. התוצאה יכולה להיות 30-40 הוראות (כ-160 בייט) בגלל הקריאות הנוספות למתודות (hasNext(), next()) וההקצאה של אובייקט האיטרטור עצמו.
    • אופטימיזציה של R8: בתנאים מסוימים (למשל, אם הוכח ש-List הוא ArrayList), כלי האופטימיזציה של R8 יכול להפוך לולאת foreach בחזרה ללולאה פשוטה עם אינדקס, וכך לבטל את התקורה של האיטרטור ולצמצם את גודל הקוד ואת השימוש בזיכרון בזמן הריצה.
  • קריאות לשיטות וירטואליות: כוללות טעינה של מחלקת האובייקט, מציאת השיטה ב-vtable ואז הסתעפות. בדרך כלל התהליך הזה דורש 4-5 הוראות (כ-20 בייט).
  • קריאות ישירות/סטטיות: מתורגמות בדרך כלל להוראה אחת של bl (Branch with Link) ‎ (4 bytes).
  • Kotlin Lambdas: יכולות ליצור מחלקות אנונימיות שלמות ושיטות גישור נוספות, ולהוסיף מאות בייטים של קוד ותקורה של מטא-נתונים לבלוק פונקציונלי פשוט.

בעזרת Compiler Explorer, אפשר לראות איך תכונות שפה מתקדמות (כמו Kotlin lambdas, ממשקי API של זרם או שימוש נרחב בגנריות) משפיעות על הגודל הסופי של האפליקציה אחרי ההידור, ואיך אופטימיזציות כמו R8 יכולות לפצות על העלות של הפשטות שפה במקרים מסוימים. הכלי הזה יכול לעזור לכם לקבל החלטות מושכלות לגבי פשרות שצריך לעשות בתכנון וביישום של אפליקציה.

באופן כללי, ככל שהקוד של האפליקציה מורכב יותר, כך השימוש בזיכרון גבוה יותר. לעומת זאת, קוד פשוט יותר – או קוד שפשוט על ידי R8 – מוביל לייצוג קטן יותר כהוראות CPU וכבייטים באחסון וב-RAM.

מדידת ההשפעה של הקוד באמצעות meminfo ו-showmap

אתם יכולים להשתמש בכלי הזיכרון הרגילים של Android כדי לראות כמה זיכרון צורך הקוד של האפליקציה.

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 מנתח את קוד הבייט של האפליקציה ומסיר את כל המחלקות או השיטות שלא נקראות אף פעם (dead code stripping).

תרגיל מעשי: העלות של ניפוח

כדי להמחיש את ההשפעה של גודל הקוד, נניח שאתם עורכים ניסוי להשוואה בין שני מבנים של אפליקציה שמכילה 300 מחלקות שנוצרו (כל אחת עם 500 שיטות):

  • CodeBloat (Unoptimized): הגרסה הרגילה והלא מותאמת שכוללת את כל המחלקות שנוצרו ואת המחרוזות הייחודיות.
  • CodeBloatOptimized: אותו קוד מקור, אבל הוא עבר קומפילציה עם הפעלת R8 shrinking.

1. הידור מראש (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 (פירוט נוסף בהמשך).

2. השקה והשוואה

כדי לראות הפעלה קרה באמת שבה המערכת צריכה לקרוא קוד מהאחסון, נשחרר את מטמון הדפים של ליבת המערכת לפני הפעלת כל אפליקציה. לשם כך נדרשת גישת root.

מפעילים את האפליקציה שלא עברה אופטימיזציה:

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
התוצאות

אם תסתכלו על השורה Code בקטע App Summary, תראו הבדל עצום:

  • לא אופטימלי Code: כ-30,000KB‏ (30MB)
  • אופטימלי Code: ‎~2,000 KB‏ (2MB)

מערכת R8 קבעה ש-500 השיטות בתוך המחלקות האלה אף פעם לא ביצעו פעולה שימושית (השיטה doSomething() קוראת רק ל-method0(), והתוצאות מתעלמות), ולכן היא הסירה כמעט את כל הקוד שנוצר באופן מלאכותי מקובץ ה-APK הסופי.

3. הצגת ההשפעה ב-Perfetto

ההשפעה של נפח הקוד המיותר ניכרת בבירור בשלב הטעינה הראשוני של האפליקציה. באופן ספציפי, מחפשים את פרוסת bindApplication בשרשור הראשי, ואת הפרוסות המקוננות שמתחילות ב-madvising, שמציינות שהמערכת מתכוננת לטעון קבצים מ-APK והקוד המהודר שלו (.odex).

באתחול קר אינטראקטיבי, המערכת mmap() וmadvise() קוד ונתונים אחרים מהקבצים האלה שנדרשים לטעינה ולהרצה של האפליקציה. הערך אחרי size=‎ בפרוסות madvising מציין כמה נתונים צריך לטעון. הטעינה מראש של הקוד של האפליקציה מתבצעת כדי להאיץ את הפעלת האפליקציה.

מההשוואה אפשר לראות שכמות קוד האפליקציה שהיה צריך לטעון מהאחסון ל-RAM הייתה גדולה בהרבה במקרה של האפליקציה המנופחת, וכתוצאה מכך משך הזמן היה ארוך יותר והאפליקציה נפתחה לאט יותר. בנוסף, בנתוני המעקב של הפעלת האפליקציה המנופחת מוצגים נתחי זמן לטעינת קובצי DEX משניים (classes2.dex, classes3.dex) שהאפליקציה המנופחת נאלצה "לפצל" כי היא לא נכנסה לקובץ DEX אחד.

לשם השוואה (הפעלה במצב התחלתי ב-Pixel 10a)
מדד ללא אופטימיזציה (CodeBloat) אופטימיזציה (CodeBloatOptimized)
base.odex madvise size ‫~7.9MB ‏ (2.0 אלפיות השנייה) ‫~16KB‏ (0.003 אלפיות השנייה)
base.apk madvise size ‫~2.4MB‏ (2.4 אלפיות השנייה) ‫~4KB‏ (0.001 אלפית השנייה)
classes2.dex madvise size ‫‎~7.3 MB (8.6 ms) לא רלוונטי
classes3.dex madvise size ‫‎~7.3 MB (8.0 ms) לא רלוונטי
משך madvising כולל ‫~21 אלפיות השנייה כ-0.004 אלפיות השנייה
ביצועים לא אופטימליים של טעינת אפליקציות

צילום מסך של ממשק המשתמש של Perfetto שבו רואים את התהליך com.android.codebloat עם הפרוסות של madvising לקובצי ה-DEX הראשיים והמשניים

אופטימיזציה של ביצועי טעינת האפליקציות

צילום מסך של ממשק המשתמש של Perfetto שבו רואים את התהליך com.android.codebloat.optimized עם פרוסת madvising קטנה אחת

ההשפעה של נפח קוד מנופח משתנה בהתאם לגודל האפליקציה, למאפיינים של מכשיר המשתמש ולעומס המערכת.

‫PerfettoSQL לטעינת ניתוח

אפשר להשתמש בשאילתות הבאות כדי לחלץ את המדדים האלה מהעקבות.

1. משך ההפעלה של האפליקציה

הנתון הזה מראה את הזמן שחלף מהרגע שבו מופעלת פעילות של אפליקציה ועד שהפעילות מציירת פריים ראשון.

INCLUDE PERFETTO MODULE android.startup.startups;

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

מידע נוסף מופיע בקטע: הסבר על מצבי ההפעלה השונים של האפליקציה

משך ההפעלה של אפליקציה מושפע מגורמים רבים שלא מפורטים במדריך הזה.

2. חילוץ של גדלים ומשכי זמן 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 %';
3. פירוט של מצב ה-thread הראשי (משך כולל לכל מצב)

השאילתה הזו מראה כמה זמן השרשור הראשי של האפליקציה בילה במצבים שונים.

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;

כך אפשר לחשוף בעיות מעניינות, למשל:

  • זמן רב במצב Runnable (R) אבל לא במצב Running: מצב כזה מצביע על כך שההפעלה של האפליקציה התעכבה בגלל תחרות על משאבי המעבד, כלומר השרשור הראשי של האפליקציה לא יכול היה לפעול כי שרשורים אחרים (אולי מאפליקציות אחרות) תפסו את המעבדים.
  • זמן רב במצב שינה שניתן להפרעה (D): בדרך כלל מצב כזה מצביע על קלט/פלט איטי או על עומס על הזיכרון שגורם לעיכוב בהפעלת האפליקציה.
  • זמן שינה (S) ארוך: המשמעות היא שהשרשור הראשי המתין ששרשורים אחרים יבצעו עבודה. לפעמים זה מצביע על מחלוקת נעילה בנתיב ההפעלה של האפליקציה (כלומר, ה-thread הראשי נחסם במשאב בלעדי שנמצא בשימוש של thread אחר באפליקציה).
4. זיכרון מקסימלי שמוגדר לקובץ (קובץ RSS)

המדד הזה קשור באופן הדוק לכמות הקוד והנתונים שהאפליקציה טוענת בהפעלה. אפליקציה 'מנופחת' יותר תגיע למספר גבוה יותר, ותגרום לעומס על הזיכרון במערכת. העומס הזה עלול לגרום לעיכוב בהפעלת האפליקציה, כי המערכת מתקשה לספק את בקשות ההקצאה, או שהיא מפנה זמן מעבד מההתמקדות בהפעלת האפליקציה להחזרת זיכרון מתהליכים אחרים כדי לספק את הצרכים המיידיים של האפליקציה המופעלת.

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

סביבת זמן הריצה ל-Android‏ (ART) יכולה לקמפל את קוד האפליקציה באחד מכמה מצבים שונים, שנקראים גם מסנני קומפיילר. למסנן הקומפיילר שנבחר יש השפעה ישירה על הזיכרון שבשימוש של האפליקציה.

  • verify: מערכת ART מבצעת רק אימות של קוד בייט. לא מתבצעת קומפילציה של AOT. הקוד מופעל באמצעות המתורגמן או עובר הידור בזמן הריצה על ידי מהדר JIT.
    • השפעה על הזיכרון: הגודל הכי קטן בדיסק. השימוש בזיכרון של קוד מקומי מועבר אל JIT Cache (זיכרון מלוכלך אנונימי).
  • speed: ‏ ART מבצע קומפילציה מלאה של AOT לכל השיטות.
    • השפעה על הזיכרון: הגודל הגדול ביותר של .odex. ממקסם את השימוש בזיכרון שנתמך על ידי קובץ (נקי).
  • speed-profile: ‏ART קומפל רק שיטות שסומנו כ-hot בפרופיל JIT.
    • השפעה על הזיכרון: גישה מאוזנת. רק הקוד הכי קריטי עובר קומפילציה מראש (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. מחכים לסיום הפעולה של ה-thread ברקע (בודקים את logcat או מחכים 5 שניות).
  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, הסיכום של האפליקציה מציג: * Code PSS: ‏8,000KB בערך * Dalvik Other (JIT): ‏25,000KB בערך

מכיוון שלא מתבצעת קומפילציה של קוד AOT, סביבת זמן הריצה צריכה לבצע קומפילציה של שיטות חמות לתוך מטמון JIT, שמופיע כזיכרון אנונימי מלוכלך (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, התוצאות משתנות באופן משמעותי: * Code PSS: ‏24,000KB בערך * Dalvik Other (JIT): ‏5,000KB בערך

הקוד של האפליקציה ממופה עכשיו מקובץ .odex כclean file-backed memory. כך מצטמצם העומס על מטמון ה-JIT והזיכרון הופך למועמד לפינוי במקרה של עומס, במקום להיתקע כ-RAM מלוכלך.

מצב: speed-profile (AOT סלקטיבי)

יכול להיות שאפליקציות מודרניות יכללו פרופיל Baseline של baseline.prof. ‫ART משתמש בזה כדי לבצע קומפילציה סלקטיבית רק של הקוד שנדרש להפעלה מהירה ויעילה של הזיכרון.

בתרגיל הזה ניצור פרופיל Baseline כדי לפרט את מחלקות ההפעלה של האפליקציה. עם זאת, בפועל, יכול להיות שהקומפיילר יקבל גם פרופילים ממקורות חיצוניים, כמו מחנות האפליקציות (פרופילים בענן), שיכולים לספק פרופילי JIT שמבוססים על מיקור המונים לאפליקציות, בלי קשר לשאלה אם המפתח צירף גם פרופיל בסיסי שהוא יצר.

יצירה ושימוש בפרופילים במכשיר

כדי לראות את ההשפעה של speed-profile, אתם יכולים ליצור פרופיל משלכם במכשיר:

  1. איפוס והתחלה:

    adb shell am force-stop com.android.codebloat
    
  2. אינטראקציה: מפעילים את האפליקציה ומאפשרים לה להריץ את רצף ההפעלה שלה.

  3. Dump Profile:

    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
    

כשמפעילים שוב, רואים את היתרה: הערך של Code PSS יהיה נמוך מ-speed (למשל, ‎~16,000 KB) כי רק שיטות ההפעלה 'החמות' עברו קומפילציה, והשאר יטופלו על ידי המפרש או JIT רק אם הם ישמשו בפועל.

כך עושים זאת:

ניתוח מעמיק של קוד שעבר קומפילציה

אם רוצים לראות בדיוק אילו הוראות נוצרות על ידי ART, אפשר לעיין בart/DISASSEMBLY_GUIDE.md.

הוא מספק הוראות מפורטות לשימוש ב:

  • oatdump: כדי לראות הוראות ARM64 בתוך קובץ .odex קיים.
  • dex2oat: כדי לדמות הידור עם דגלי ניפוי באגים מפורטים.

תרגיל: החדרת קוד

אחת הסיבות לכך שקוד שעבר קומפילציה יכול לגדול באופן בלתי צפוי היא הטמעה של שיטות. הקומפיילר עשוי להחליט להעתיק את הגוף של שיטה קטנה שמופעלת לעיתים קרובות ישירות לשיטות שקוראות לה.

באפליקציית CodeBloat, השיטה doSomething() בכל מחלקה שנוצרה פשוט קוראת ל-method0(). כשמבצעים קומפילציה במצב speed, קומפיילר האופטימיזציה של ART כנראה יבצע inline של 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 בפלט. אם הוא הוטמע, תראו את ההוראות לטעינת הקבוע הארוך של המחרוזת ישירות בתוך doSomething, ולא הוראה של bl שמטרגטת את method0.

הדמיה של האופטימיזציה (CFG)

כדי לראות בדיוק מתי הקומפיילר החליט להטמיע את השיטה, אפשר ליצור תרשים של זרימת הבקרה (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. מציאת ה-Inliner: ב-IR Hydra, טוענים את ארטיפקטים של הקומפילציה ומחפשים את doSomething. משווים את הייצוג לפני ואחרי שלב Inliner. הגרף יתרחב כשההוראות מ-method0 ישולבו בשיחה.

אפשר גם להשתמש בכלי Opt Pipeline ב-Compiler Explorer (כפי שמתואר בקטע שלמעלה) ולהזין קוד דומה כדי לראות טרנספורמציה דומה שמתבצעת במעבר Inliner.

תרגיל: שדות משתנים ומחסומי זיכרון

באפליקציה MemoryLab, השדה mGarbageSink מסומן כ-volatile. כך מוודאים שהקומפיילר לא יבצע אופטימיזציה ויסיר את הקצאות הזבל שלנו.

public volatile byte[] mGarbageSink;

בפירוק של ARM64, אפשר לראות שכל אחסון בשדה הזה מלווה במחסום זיכרון (dmb ish) או בשימוש בהוראות Load-Acquire/Store-Release (ldar/stlr). כך מובטחת נראות השרשור, אבל מתווספות כמה הוראות נוספות לכל גישה, מה שמגדיל מעט את גודל הקוד בהשוואה לשדה רגיל.

תרגיל: מוצאים את הגישות לשדות ואת מחסומי הזיכרון המשויכים בפירוק.

תרגיל: בדיקות השעיה משתמעות

אם מפרקים לולאה, כמו זו שבאיור generateAllocationChurn, אפשר לראות הוראה מוזרה בסוף גוף הלולאה:

ldr x21, [x21]

זוהי בדיקת השעיה משתמעת. ‫ART משתמש בזה כדי לאפשר ל-Garbage Collector להשהות את השרשורים בצורה בטוחה. רשומת x21 בדרך כלל מצביעה על עצמה. כשה-GC צריך להשהות את השרשור, הוא 'מרעיל' את מיקום הזיכרון הזה. בפעם הבאה שהשרשור יבצע את הפעולה ldr, תופעל שגיאה שסביבת זמן הריצה תתפוס ותשתמש בה כדי להעביר את השרשור למצב מושהה.

התבנית הזו חוזרת בכל לולאה ובתחילת כל שיטה, וכך תורמת לגודל הכולל של הקוד באפליקציה.

תרגיל: מאתרים את כל הבדיקות המרומזות של השעיה בפירוק של השיטה, ומנסים לקשר אותן לקוד המקור המקורי.


← WebView | ↑ למעלה | שרשורים →