חדשות על המוצר

‫18% יותר מהר, 0% פשרות

משך הקריאה: 8 דקות
הצגת הפרופיל של Santiago Aboy Solanes הצגת הפרופיל של צ'ארלס מאנגר
Santiago Aboy Solanes וגם Vladimír Marko

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

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

חלק מהשיפורים האלה במהירות הקימפול הושקו בגרסת Android של יוני 2025, והשאר יהיו זמינים בגרסת Android של סוף השנה. בנוסף, כל משתמשי Android בגרסאות 12 ומעלה יכולים לקבל את השיפורים האלה דרך עדכוני Mainline.

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

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

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

איך למצוא אופטימיזציות אפשריות שכדאי לבצע

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

 

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

 

בעזרת קובצי ה-APK שנבחרו, הפעלנו קומפילציה ידנית באופן מקומי, קיבלנו פרופיל של הקומפילציה והשתמשנו ב-pprof כדי לראות איפה אנחנו משקיעים את הזמן שלנו.

image.png

דוגמה לתרשים להבות של פרופיל ב-pprof

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

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

image.png

תצוגה מלמטה למעלה של פרופיל

במהדר האופטימיזציה שלנו, יש שלב שנקרא Global Value Numbering (GVN). אתם לא צריכים לדאוג לגבי מה שהיא עושה באופן כללי, אבל החלק הרלוונטי הוא לדעת שיש לה method שנקראת `Kill` שתמחק כמה צמתים לפי מסנן. זה לוקח זמן כי צריך לעבור על כל הצמתים ולבדוק אותם אחד אחד. שמנו לב שיש מקרים שבהם אנחנו יודעים מראש שהבדיקה תיכשל, לא משנה אילו צמתים פעילים באותו שלב. במקרים כאלה, אנחנו יכולים לדלג על החזרה לחלוטין, להוריד אותה מ-1.023% ל-0.3% בערך ולשפר את זמן הריצה של GVN ב-15% בערך.

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

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

בדרך כלל, במקרה כמו של `Kill` שלמעלה, נבדוק איך אנחנו עוברים על הצמתים ונבצע את זה מהר יותר, למשל, על ידי ביצוע פעולות במקביל או שיפור האלגוריתם עצמו. למעשה, זה מה שניסינו בהתחלה, ורק כשלא מצאנו שום דבר לעשות, היה לנו רגע של "רגע, חכו…" והבנו שהפתרון הוא (במקרים מסוימים) לא לחזור על הפעולה בכלל! כשמבצעים אופטימיזציות כאלה, קל לפספס את התמונה הגדולה.

במקרים אחרים, השתמשנו בכמה שיטות שונות, כולל:

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

איך אפשר לדעת אם כדאי ליישם את האופטימיזציות?

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

אם אתם נמצאים במצב דומה, נסו להעריך עד כמה אתם יכולים לשפר את המדד במינימום מאמץ. זה אומר, לפי הסדר:

  1. הערכה לפי מדדים שכבר אספתם, או פשוט לפי תחושת בטן
  2. הערכה באמצעות אב טיפוס מהיר ופשוט
  3. מטמיעים פתרון.

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

בחינה מעמיקה

בלי להכביר במילים, בואו נראה כמה מהשינויים שהטמענו.

ביצענו שינוי כדי לשפר שיטה שנקראת FindReferenceInfoOf. השיטה הזו ביצעה חיפוש לינארי של וקטור כדי למצוא רשומה. עדכנו את מבנה הנתונים הזה כך שיכלול אינדקס לפי מזהה ההוראה, כדי ש-FindReferenceInfoOf יהיה O(1)‎ במקום O(n)‎. בנוסף, הקצינו מראש את הווקטור כדי למנוע שינוי גודל. הגדלנו מעט את הזיכרון כי היינו צריכים להוסיף שדה נוסף שספר כמה רשומות הוספנו לווקטור, אבל זו הייתה הקרבה קטנה כי הזיכרון המקסימלי לא גדל. השינוי הזה האיץ את שלב ה-LoadStoreAnalysis ב-34-66%, וכתוצאה מכך שיפר את משך הזמן לקימפול ב-0.5-1.8% בערך.

יש לנו הטמעה מותאמת אישית של HashSet שבה אנחנו משתמשים בכמה מקומות. יצירת מבנה הנתונים הזה נמשכה זמן רב, וגילינו למה. לפני הרבה שנים, מבנה הנתונים הזה שימש רק בכמה מקומות שהשתמשו ב-HashSet גדולים מאוד, והוא שונה כדי לבצע אופטימיזציה לכך. עם זאת, כיום הוא משמש בכיוון ההפוך עם מעט מאוד ערכים ועם אורך חיים קצר. זה אומר שבזבזנו מחזורי עיבוד על יצירת HashSet ענק, אבל השתמשנו בו רק לכמה רשומות לפני שהשלכנו אותו. השינוי הזה שיפר את משך הזמן לקימפול בכ-1.3% עד 2%. בונוס נוסף: השימוש בזיכרון ירד בכ-0.5-1% כי לא השתמשנו במבני נתונים גדולים כמו קודם.

שיפרנו את משך הזמן לקימפול בכ-0.5-1% על ידי העברת מבני נתונים באמצעות קובץ עזר ל-lambda כדי להימנע מהעתקה שלהם. זה משהו שפספסנו בבדיקה המקורית, והוא נשאר בבסיס הקוד שלנו במשך שנים. בזכות העיון בפרופילים ב-pprof, שמנו לב שהשיטות האלה יוצרות ומשמידות הרבה מבני נתונים, מה שהוביל אותנו לחקור ולבצע אופטימיזציה שלהם.

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

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

העברנו שתי בדיקות מהקטגוריה "בדיקות סופיות" לקטגוריה "היוריסטיקה" כדי להעריך אם הטמעה תצליח או לא לפני שנבצע חישוב שדורש זמן רב. מכיוון שמדובר בהערכה, היא לא מושלמת, אבל וידאנו שההיוריסטיקה החדשה שלנו מכסה 99.9% ממה שהיה מוטמע קודם לכן בלי להשפיע על הביצועים. אחת מההיוריסטיקות החדשות האלה הייתה קשורה לאוגרי DEX הדרושים (שיפור של כ-0.2-1.3%), והשנייה הייתה קשורה למספר ההוראות (שיפור של כ-2%).

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

בנוסף, המחלקה BitVectorView הוגדרה כתבנית לפי סוג האחסון הבסיסי (במקום להשתמש תמיד ב-uint32_t כמו ב-BitVector הישן). כך אפשר לעבד כמות כפולה של ביטים ביחד בפעולות מסוימות, כמו Union()‎, בפלטפורמות של 64 ביט. הדגימות של הפונקציות המושפעות הופחתו ביותר מ-1% בסך הכול במהלך הידור של Android OS. השינוי הזה בוצע בכמה שלבים [1, 2, 3, 4, 5, 6]

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

סיכום

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

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

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

להמשך קריאה