איך R8 שיפר את הביצועים של שגרות המשך (coroutines) ב-Kotlin ב-Android פי 2
משך הקריאה: 7 דקות
החל מ-AGP 9.2.0, R8 מבצע אופטימיזציה לרוב הקריאות של Atomic*FieldUpdater לגרסאות Unsafe שמניבות ביצועים טובים פי 2 עד פי 4 בפעולות נפוצות. השינוי הזה משפיע במיוחד על הספרייה kotlinx.atomicfu שמטמיעה אטומיות עבור kotlinx.coroutines, וכך משיקה ומבטלת קורוטינות במהירות גבוהה פי 2. כדי ליהנות מההטבות, צריך לעדכן את AGP לגרסה 9.2.0 ואילך.
רוב האפליקציות ל-Android משתמשות ב-Kotlin כשפה העיקרית שלהן, ולכן kotlinx.coroutines הפכה לסטנדרט בפועל לתכנות אסינכרוני. הספרייה מציעה דרך מובנית ב-Kotlin לניהול זרימות מקבילות, שהיא גם מעוצבת ומובנית היטב. Jetpack Compose לא היה יוצא דופן, והוא אימץ קורוטינות לניהול אירועי הצבעה, אנימציות ואינטראקציות אחרות. בזמן כתיבת המאמר הזה, רוב ממשקי ה-API המקבילים ב-Compose קוראים לפונקציות suspend מתחת לפני השטח, ומפעילים או מבטלים קורוטינות כדי לטפל בעדכונים.
כשצוות Compose התחיל לבדוק את הביצועים, הוא גילה שקורוטינות הן צוואר בקבוק עבור הרבה פעולות שמתבצעות מחוץ להרכבה. לדוגמה, 80% מהזמן שהוקדש ליצירה ולעדכון של Modifier.clickable נוצל להפעלת קורוטינות פנימיות ולביטול שלהן, שטיפלו בעדכונים של InteractionSource. על סמך התצפיות האלה, רוב העבודה על שיפור הביצועים בשלב מוקדם התמקדה בהסרת קורוטינות מנתיב ברירת המחדל ובהשהיית האתחול עד שנדרש.
העלות של שגרת המשך (coroutine)
הדרך הקלה ביותר לנתח את ההתנהגות הפנימית של פונקציה ב-Android היא ללכוד מעקב אחר שיטת Android Runtime (ART). תיעוד method ב-ART הוא כלי שמתעד את זרימת ההרצה של אפליקציה, ומראה בדיוק אילו שיטות מופעלות, באיזה סדר וכמה זמן מושקע בכל אחת מהן. כך המפתחים יכולים לזהות צווארי בקבוק בביצועים. במקרה של קריאה ריקה LaunchedEffect { }, היא תיראה בערך כך:
אפשר לחלק את מעקב השיטה שלמעלה לשלושה חלקים:
- הפעלה של שגרת המשך (coroutine) חדשה
- התחלת שגרת המשך (coroutine)
- השלמת קורוטינה (כי היא יוצאת מיידית)
ביטול LaunchedEffect דומה להשלמה רגילה, אבל הוא גם יוצר CancellationException.
בפרופיל שלמעלה, דבר אחד שמעורר חשד באופן מיידי הוא שיחות תכופות אל java.util.concurrent.AtomicReferenceFieldUpdater (תיבות סגולות או ירוקות עם תוויות j…). כל קריאה היא יחסית מהירה, אבל התדירות שלה מדאיגה. כל תקורה משמעותית שמתפרסת על פני כמה הפעלות עלולה להצטבר לרגרסיה ניכרת. אם מתמקדים בשיחה, אפשר לראות שרוב הזמן מושקע ב... בדיקות של השתקפויות?
קורוטינות מטמיעות מבנה עץ ללא נעילה לקשרים של הורה/צאצא, שמאפשרת מקביליות מובנית. מסתבר שבספרייה kotlinx.atomicfu מיושמות פעולות אטומיות ללא נעילה באמצעות פרימיטיב JVM מוכר, AtomicReferenceFieldUpdater. העדכון משתמש בהפניה למחלקה ובשם שדה כדי לבצע פעולות אטומיות בזמן ריצה, והוא צריך להריץ כמה בדיקות בטיחות רפלקטיביות כדי לוודא שהשדה קיים ושיש אליו גישה. כל פעולה ב-coroutines (התחלה, השהיה, ביטול, השלמה) קוראת לפחות פעולה אטומית אחת, ולכן אם היא איטית, הביצועים של ה-coroutines לא יהיו טובים.
בדיקת AtomicReferenceFieldUpdater
אבל בואו לא נקדים את המאוחר. AtomicReferenceFieldUpdater עבר אופטימיזציה טובה ב-JVM במשך יותר מ-10 שנים, ועקבות של שיטות עשויות לתעד תקורה שמוסרת לחלוטין על ידי אופטימיזציה ברמת המכונה הווירטואלית: קימפולים בזמן אמת (JIT) או מראש (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 (תוך הקפדה על קומפילציה של AtomicReferenceFieldUpdater#compareAndSet ב-JIT במהלך החימום) מניבה את התוצאות הבאות ב-Pixel 5 (API 33):
50.7 ns atomicReference_compareAndSet 135 ns atomicRef_compareAndSet
הנתונים מאשרים את הפער, וגרסה kotlinx.atomicfu איטית פי 2.7 בערך. הבדיקה הזו מאשרת ש-ART לא מבצע אופטימיזציה נסתרת, ובדיקות הגישה הרפלקטיבית מוסיפות תקורה אמיתית במהלך זמן הריצה.
אם נסתכל שוב על מעקב השיטה המקורית, נראה שהפעולה המשמעותית היחידה שמתבצעת על ידי AtomicReferenceFieldUpdater היא הקריאה הפנימית אל Unsafe.getObjectVolatile, שבעצם מבצעת את הפעולה האטומית הבסיסית. ברוב המקרים, מאתחל העדכון הוא סטטי, ואפשר להוכיח שהוא תמיד נכון על סמך המבנה של המחלקה שמסביב. לכן, אפשר לנתח באופן סטטי את רוב השימושים ב-AtomicReferenceFieldUpdater ולהחליף אותם בווריאציה פנימית של Unsafe במהלך הקומפילציה. במקרה, לשרשרת הכלים לבניית אפליקציות ל-Android יש מהדר אופטימיזציה משלה שיכול לעשות בדיוק את זה.
אופטימיזציה באמצעות R8
מחלקות Atomic*FieldUpdater תומכות בשימוש עדין, דינמי ומבוסס-השתקפות, אבל לרוב משתמשים בהן בתבניות סטטיות ברורות. ההסבר הזה גם מסביר את הביצועים הבסיסיים הנמוכים וגם את הצורך באופטימיזציה. R8 הוא קומפיילר שמבצע אופטימיזציה של תוכנית מלאה, והוא מתאים במיוחד לזיהוי דפוסים פשוטים יותר כדי לצמצם את התקורה של בדיקות הבטיחות הרפלקטיביות. R8 מקבל bytecode של JVM אחרי קומפיילר Java או Kotlin, אבל כדי להקל על הקריאה, הדוגמאות האלה מוצגות בתחביר של Java. לכן אין ארגומנטים של סוגים בפונקציה 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 היא עטיפה מסביב להיסט של שדה ולקריאות אל Unsafe. תרחיש האופטימיזציה הטוב ביותר הוא להחליף את שדה העדכון בשדה היסט ולהחליף את הקריאות לעדכון בקריאות ל-Unsafe.
אופטימיזציה של Atomic*FieldUpdater
האופטימיזציה מיושמת בשלושה חלקים: הטמעה, החלפה וניקוי.
אינסטרומנטציה
השלב הראשון הוא להוסיף שדות של היסט לצד שדה העדכון כדי לאפשר גישה ישירה באמצעות Unsafe הקריאה.
static final long updater$offset = SyntheticUnsafe.UNSAFE.objectFieldOffset(Example.class.getDeclaredField("data"))
הגישה לשדה מתבצעת באמצעות רפלקציה, והערך Unsafe משמש לחילוץ ההיסט של השדה במחלקה. הקוד הזה מייצג את הפנימיות של Atomic*FieldUpdater אם מתעלמים מאימות ההשתקפות. במקום זאת, סוג המחזיק של הפונקציה לעדכון וסוג השדה של השדה הדינמי מתועדים באופן סטטי בקומפיילר.
שימו לב שהשדה המקורי וההגדרה שלו נשארים ללא שינוי. תהליך האופטימיזציה מאפשר שימוש אופטימלי בשדה, ואז מנקה אותו. זוהי גישה פשוטה להטמעה, אבל היא גם מאפשרת אופטימיזציה חלקית של שדות העדכון, כך שחלק מהשימושים נשארים כמו שהם ואחרים עוברים אופטימיזציה.
החלפה
בשלב הזה של הקומפיילר, אחרי נקודת הצטרפות מתאימה של בו-זמניות, יש לנו רשימה של שדות מעדכנים עם אינסטרומנטציה. המשמעות היא שאנחנו יכולים לבצע אופטימיזציה לכל אתר קריאה בנפרד על סמך כמה תנאים. דוגמה לשיחה:
updater.compareAndSet(holder, expectedValue, newValue);
התנאים שנדרשים כדי להשתמש ב-Atomic*FieldUpdater הם:
- האם
updaterמגיע משדה עם כלי מדידה? כלומר, האם ניתוח סטטי יכול לעקוב אחרי הערך של האובייקט עד לקריאת שדה של כלי לעדכון עם מכשור? - האם
holderהוא מאותו סוג או מסוג משנה של סוג המחזיק שהוגדר במקור? - האם
newValueהוא אותה מחלקה או מחלקת משנה של סוג השדה שהוגדר במקור?
אם כל התנאים מתקיימים, השיחה מוחלפת בשיחה אל Unsafe בלי בדיקות ההשתקפות.
SyntheticUnsafe.UNSAFE.compareAndSwapObject(holder, Example.updater$offset, expectedValue, newValue)
הקריאה החדשה מהירה ופשוטה יותר, אבל היא שונה מהקריאה המקורית מבחינת הטיפול שלה בערכי null ב-updater וב-holder. אלא אם נפסלו באופן סטטי, בדיקות null מוכנסות לשניהם.
ניקוי תלונות
בשלב הזה, למחלקת ההמתנה יש את שדה העדכון המקורי ואת שדה ההיסט החדש, לצד אתרי קריאה שעשויים להשתמש באחד משני השדות. אם אף אחד מהאתרים שבהם מתבצעת השיחה לא עבר אופטימיזציה, צריך להסיר את שדה ההיסט. אם כל האתרים שבהם מתבצעת השיחה עברו אופטימיזציה, צריך להסיר את שדה העדכון. בשני המקרים, צריך למחוק גם את קריאת האתחול. המחיקה של שדות שלא נעשה בהם שימוש וההסרה של קוד לא פעיל כבר מתבצעות בקומפיילר, אבל כדי להסיר את קוד האתחול כאן צריך להשתמש בעוד כמה טריקים.
יכול להיות שגם לקריאה ל-newUpdater וגם לקריאה ל-getDeclaredField יהיו תופעות לוואי, כי הן יכולות להחזיר חריגים (וגם ההטמעה שלהן לא ידועה כי היא תלויה בגרסת ה-API). כלומר, אי אפשר להסיר אותם בבטחה באמצעות אופטימיזציה כללית. לכן, כדי לנקות את הקוד, היה צריך להתייחס במפורש לשדות שנוספו להם כלי מדידה, כי ידוע שהם לא מכילים חריגים.
בסופו של דבר, דוגמת העדכון הפשוטה שמוצגת למעלה נראית כך אחרי האופטימיזציה:
תוצאות
אחרי האופטימיזציות האלה, הביצועים של kotlinx.atomicfu ושל רוב השימושים המפורשים ב- AtomicInt/Long/ReferenceFieldUpdater זהים לביצועים של AtomicReference עם R8. למעשה, הוא אפילו מהיר יותר בחלק מהבדיקות. ל-kotlinx.atomicfu יש תוסף קומפיילר שיכול להוסיף מקרים של atomic לשדות, וכך לצמצם את ההקצאות שנדרשות ליצירת שדה שמתעדכן באופן אטומי.
Jetpack Compose הייתה המרוויחה העיקרית מהעבודה הזו. בזמן הריצה של Compose יש מספר מיקרו-בנצ'מרקים שעוקבים אחרי הביצועים של שגרות המשך (coroutine) באופן מדויק מאוד, כדי לזהות רגרסיות בביצועים בשלב מוקדם. כשעדכנו את המדדים לגרסה חדשה של R8, שמנו לב לשיפור של פי 2 בהפעלה וביטול של קורוטינות ב-LaunchedEffect.
בנוסף, צוות ART מיישם את האופטימיזציות האלה באופן מקורי ברמת המכונה הווירטואלית. אם האפליקציה מטרגטת API 36 ופועלת בגרסה עדכנית של Android, יכול להיות שהמכשיר כבר מבצע אופטימיזציה של קורוטינות באופן דומה. בבדיקות הביצועים של קורוטינות שצוינו למעלה, נצפה שיפור של כ-15% בביצועים אחרי עדכוני JIT בגרסאות האחרונות של ART.
האפליקציה שלכם תעבור אופטימיזציה כברירת מחדל אם תשדרגו ל-AGP 9.2.0 או אם תשתמשו ישירות ב-R8 9.2.0. מידע נוסף זמין במאמר בנושא D8 dexer ו-R8 shrinker.
-
Case Studiesהצוות יצר בסיס קוד של ממשק משתמש מבוסס-AI, שהוא קטן ב-50% מההטמעה המקורית, ובו בזמן השיג קיצור של 35% בזמן הביצוע של סוכן ה-AI, 32% פחות אינטראקציות בין מהנדס לסוכן וחיסכון של 33% בעלות האסימונים.
Pavlo Stavytskyi, Rebecca Franks • משך הקריאה: 11 דקות -
Case Studiesוואטסאפ היא פלטפורמת המסרים הגדולה בעולם, עם מיליארדי משתמשים ברחבי העולם. זהו כלי התקשורת שברירת המחדל שלו היא הצפנה מקצה לקצה, והוא מאפשר לאנשים באזורים שונים לתקשר ביניהם בצורה פרטית, אמינה ומאובטחת.
Niharika Arora, Tracy Agyemang, Mayank Jain • משך הקריאה: 8 דקות -
Case Studiesהמטרה של Tinder היא לעזור לרווקים ורווקות מכל הדורות ליצור קשרים אמיתיים בקלות ובכיף.
Ajesh Pai, Ulises Uriel Verduzco Díaz , Tracy Agyemang • משך הקריאה: 4 דקות
רוצים לקבל טיפים עדכניים לפיתוח Android ישירות לאימייל כל שבוע?