החל מ-AGP 9.2.0, R8 מבצע אופטימיזציה לרוב הקריאות ל-Atomic*FieldUpdater והופך אותן לווריאנטים לא בטוחים שמניבים ביצועים טובים פי 2 עד פי 4 בפעולות נפוצות. יש לכך השפעה גדולה במיוחד על ספריית kotlinx.atomicfu שמטמיעה פעולות אטומיות ב-kotlinx.coroutines, ומאפשרת להפעיל ולבטל פונקציות coroutine במהירות גבוהה עד פי 2. כדי ליהנות מהיתרונות, צריך לעדכן את ה-AGP לגרסה 9.2.0 ואילך.
רוב האפליקציות ל-Android משתמשות ב-Kotlin כשפה העיקרית שלהן, ו-kotlinx.coroutines הפך לתקן דה פקטו לתכנות אסינכרוני. הספרייה מציעה דרך מובנית ומעוצבת היטב לניהול תהליכים בו-זמניים, שהיא חלק מ-Kotlin. גם ב-Jetpack Compose נעשה שימוש ב-coroutines לניהול אירועי מצביע, אנימציות ואינטראקציות אחרות. נכון לזמן כתיבת המאמר, רוב ממשקי ה-API בו-זמנית ב-Compose קוראים לפונקציות suspend מאחורי הקלעים, ומפעילים או מבטלים פונקציות Coroutine כדי לטפל בעדכונים.
כשהצוות של Compose התחיל לבדוק את הביצועים, הוא גילה ש-coroutines הן צוואר בקבוק להרבה פעולות שמתרחשות מחוץ לקומפוזיציה. לדוגמה, 80% מהזמן שהושקע ביצירה ובעדכון של Modifier.clickable הוקדש להפעלה ולביטול של פונקציות פנימיות מסוג coroutine שטיפלו בעדכונים של InteractionSource. על סמך התצפיות האלה, חלק גדול מהעבודה המוקדמת על הביצועים התמקד בהסרת פונקציות Coroutine מהנתיב שמוגדר כברירת מחדל, ובעיכוב של תהליך ההפעלה עד שיהיה צורך.
העלות של שגרת המשך
הדרך הקלה ביותר לנתח את ההתנהגות הפנימית של פונקציה ב-Android היא לתעד את ה-method של סביבת זמן ריצה ל-Android (ART). תיעוד method של ART הוא כלי שמתעד את תהליך ההפעלה של אפליקציה, ומציג בדיוק אילו שיטות נקראות, את הסדר שלהן וכמה זמן נמשכת כל אחת מהן. כך המפתחים יכולים לזהות צווארי בקבוק בביצועים. בקריאה ריקה ל-LaunchedEffect { }, זה ייראה בערך כך:
אפשר לחלק את תיעוד ה-method שלמעלה לשלושה חלקים:
- הפעלת שגרת המשך (coroutine) חדשה
- הפעלת קורוטינה
- השלמת קורוטינה (כי היא נסגרת מיד)
ביטול של LaunchedEffect דומה להשלמה רגילה, אבל הוא גם יוצר CancellationException.
בפרופיל שלמעלה, דבר אחד שמעורר חשד באופן מיידי הוא שיחות תכופות אל java.util.concurrent.AtomicReferenceFieldUpdater (תיבות סגולות או ירוקות עם תוויות j…). כל קריאה היא מהירה יחסית, אבל התדירות מדאיגה. כל תקורה לא זניחה שמתפרסת על פני כמה קריאות עלולה להצטבר לרגרסיה משמעותית. אם נתמקד בשיחה, נראה שרוב הזמן מוקדש ל... בדיקות של השתקפות?
פונקציות Coroutine מטמיעות מבנה עץ ללא נעילה לקשרים של הורה-צאצא, שמאפשרים בו-זמניות מובנית. מסתבר שהספרייה kotlinx.atomicfu מטמיעה פעולות אטומיות ללא נעילה באמצעות פרימיטיב JVM מוכר, AtomicReferenceFieldUpdater. העדכון משתמש בהפניה למחלקה ובשם שדה כדי לבצע פעולות אטומיות בזמן הריצה, והוא צריך להריץ כמה בדיקות בטיחות רפלקטיביות כדי לוודא שהשדה קיים ונגיש. כל פעולה ב-coroutine (התחלה, השעיה, ביטול, השלמה) קוראת לפחות לפעולה אטומית אחת, כך שאם היא איטית, ה-coroutine לא יפעל היטב.
Investigating AtomicReferenceFieldUpdater
אבל בואו לא נקדים את המאוחר. AtomicReferenceFieldUpdater למעשה עבר אופטימיזציה טובה ב-JVM כבר יותר מ-10 שנים, וייתכן שתיעודי method יתעדו תקורה שהוסרה לחלוטין על ידי אופטימיזציה ברמת ה-VM: קימפולים מסוג just-in-time (JIT) או ahead-of-time (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 לא מבצע אופטימיזציה נסתרת, ובדיקות גישה רפלקטיביות מוסיפות תקורה אמיתית במהלך זמן הריצה.
אם נחזור לתיעוד ה-method המקורי, העבודה המשמעותית היחידה שבוצעה על ידי AtomicReferenceFieldUpdater היא הקריאה הפנימית אל Unsafe.getObjectVolatile, שמבצעת בפועל את הפעולה האטומית הבסיסית. ברוב המקרים, המאתחל של העדכון הוא סטטי, ואפשר להוכיח שהוא תמיד נכון על סמך המבנה של המחלקה שמסביב. לכן, אפשר לנתח באופן סטטי את רוב השימושים ב-AtomicReferenceFieldUpdater ולהחליף אותם בגרסה פנימית של Unsafe במהלך ההידור. במקרה, ל-toolchain של Android build יש קומפיילר אופטימיזציה משלו שיכול לעשות בדיוק את זה.
אופטימיזציה באמצעות R8
המחלקות של Atomic*FieldUpdater תומכות בשימוש עדין, דינמי ומבוסס-רפלקציה, אבל לעיתים קרובות משתמשים בהן בתבניות סטטיות ברורות. זה מסביר גם את הביצועים האיטיים של נקודת ההתחלה וגם את הרצון לבצע אופטימיזציה. R8 הוא קומפיילר לאופטימיזציה של תוכניות מלאות, והוא מתאים לזיהוי התבניות הפשוטות יותר כדי לצמצם את התקורה של בדיקות הבטיחות הרפלקטיביות. R8 מקבל קוד בייט של 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 מוכנסות לשניהם.
ניקוי תלונות
בשלב הזה, למחלקת ההחזקה יש את שדה העדכון המקורי ואת שדה ההיסט החדש, יחד עם אתרי קריאה שעשויים להשתמש באחד משני השדות. אם אף אחד מהאתרים לא עבר אופטימיזציה, צריך להסיר את השדה offset. אם כל האתרים עברו אופטימיזציה, צריך להסיר את השדה updater. בשני המקרים, צריך למחוק גם את קריאת האתחול. מחיקת שדות שלא בשימוש והסרת קוד מת כבר מתבצעות בקומפיילר, אבל כדי להסיר את קוד האתחול כאן צריך עוד כמה טריקים.
גם הקריאה ל-newUpdater וגם הקריאה ל-getDeclaredField עלולות לגרום לתופעות לוואי כי הן יכולות להוביל לחריגות (וגם ההטמעה שלהן לא ידועה כי היא תלויה בגרסת ה-API). כלומר, אי אפשר להסיר אותם בבטחה באמצעות אופטימיזציה כללית. לכן, הניקוי הזה דרש התייחסות מפורשת לשדות המכילים, כי ידוע באופן סטטי שהם נקיים מחריגים.
בסופו של דבר, הדוגמה הפשוטה של עדכון שמוצגת למעלה נראית כך אחרי אופטימיזציה:
תוצאות
אחרי האופטימיזציות האלה, kotlinx.atomicfu ורוב השימושים המפורשים ב-AtomicInt/Long/ReferenceFieldUpdater תואמים עכשיו לביצועים של AtomicReference עם R8. למעשה, הוא אפילו מהיר יותר בחלק מהבנצ'מרקים; ל-kotlinx.atomicfu יש פלאגין של קומפיילר שיכול להטמיע מופעים של atomic בשדות, וכך לצמצם את ההקצאות שנדרשות כדי ליצור שדה שמתעדכן באופן אטומי.
הנהנה העיקרי מהעבודה הזו היה Jetpack Compose. לסביבת זמן הריצה של Compose יש כמה בדיקות ביצועים (microbenchmarks) שעוקבות מקרוב אחרי הביצועים של פונקציות ה-coroutine כדי לזהות רגרסיות בביצועים בשלב מוקדם. כשהבנצ'מרקים עודכנו לגרסה חדשה של R8, שמנו לב לשיפור של פי 2 בהפעלה ובביטול של פונקציות coroutine ב-LaunchedEffect.
בנוסף, צוות ART מטמיע את האופטימיזציות האלה באופן מקורי ברמת ה-VM. אם האפליקציה מטרגטת את API 36 ופועלת בגרסה עדכנית של Android, יכול להיות שהמכשיר כבר מבצע אופטימיזציה של פונקציות coroutine באופן דומה. בבדיקות הביצועים של פונקציות ה-coroutine שצוינו למעלה, נצפה שיפור של כ-15% בביצועים אחרי עדכוני JIT בגרסאות האחרונות של ART.
האפליקציה שלכם תעבור את האופטימיזציה הזו כברירת מחדל כשתשדרגו ל-AGP 9.2.0 או כשתשתמשו ישירות ב-R8 9.2.0. מידע נוסף זמין במאמר D8 dexer and R8 shrinker.
-
מקרים לדוגמה Grab מפחיתה את שגיאות ה-ANR ב-32% ומשפרת את זמן ההפעלה ב-35% באמצעות R8 ואופטימיזציה מבוססת-פרופיל
Grab היא אפליקציית-העל המובילה בדרום-מזרח אסיה, שמציעה משלוחים, ניידות ושירותים פיננסיים למיליוני משתמשים בשמונה מדינות באמצעות אפליקציה אחת.
Ajesh Pai, Nguyen Van Minh, Tracy Agyemang • משך הקריאה: 5 דקות -
מקרים לדוגמההצוות בנה בסיס קוד לממשק משתמש מבוסס-AI שקטן ב-50% מההטמעה המקורית, ובו-זמנית השיג הפחתה של 35% בזמן הביצוע של סוכן ה-AI, הפחתה של 32% בחילופי המידע בין המהנדסים לסוכן והפחתה של 33% בעלות הטוקנים.
Pavlo Stavytskyi, Rebecca Franks • משך הקריאה: 11 דקות -
מקרים לדוגמהוואטסאפ היא פלטפורמת ההודעות הגדולה בעולם, עם מיליארדי משתמשים ברחבי העולם. היא כלי התקשורת שמשמש כברירת מחדל אנשים באזורים שונים, ומחברת בין משתמשים באמצעות הודעות פרטיות, אמינות ומאובטחות.
Niharika Arora, Tracy Agyemang, Mayank Jain • משך הקריאה: 8 דקות
רוצים לקבל טיפים עדכניים לפיתוח Android ישירות לאימייל כל שבוע?