ללא קשר לסוג האפליקציה שאתם מפתחים, חשוב מאוד לבצע אופטימיזציה של הביצועים שלה ולוודא שהיא נטענת במהירות ומציעה אינטראקציות חלקות. דרך אחת לעשות זאת היא לבדוק את הפעילות של אפליקציה באמצעות כלי הפרופיילינג כדי לראות מה קורה מתחת למכסה הקדמית בזמן שהיא פועלת בחלון זמן. חלונית הביצועים בכלי הפיתוח היא כלי נהדר ליצירת פרופילים שנועד לנתח ולשפר את הביצועים של אפליקציות אינטרנט. אם האפליקציה שלכם פועלת ב-Chrome, היא מספקת סקירה כללית ויזואלית של הפעולות שהדפדפן מבצע בזמן שהאפליקציה פועלת. הבנת הפעילות הזו יכולה לעזור לכם לזהות דפוסים, צווארי בקבוק ונקודות חמות של ביצועים לצורך שיפור הביצועים.
אפשר להיעזר בדוגמה הבאה כדי להשתמש בחלונית ביצועים.
הגדרה ויצירה מחדש של תרחיש הפרופיילינג שלנו
לאחרונה הגדרנו יעד לשיפור הביצועים של החלונית ביצועים. במיוחד רצינו שהתכונה תטען כמויות גדולות של נתוני ביצועים במהירות רבה יותר. זה המצב, לדוגמה, כשיוצרים תהליכים ממושכים או מורכבים, או כשמתעדים נתונים ברמת פירוט גבוהה. כדי לעשות זאת, היה צורך בהבנה של ביצועי הביצועים של האפליקציה ומדוע היא פעלה כך. לשם כך, הושג באמצעות כלי ליצירת פרופילים.
כפי שאולי ידוע לך, כלי הפיתוח עצמו הוא אפליקציית אינטרנט. לכן, אפשר להציג את הפרופיל בחלונית ביצועים. כדי ליצור פרופיל של החלונית עצמה, אפשר לפתוח את כלי הפיתוח ואז לפתוח מופע אחר של כלי הפיתוח שמחובר אליה. ב-Google, ההגדרה הזו נקראת DevTools-on-DevTools.
כשההגדרה מוכנה, צריך ליצור מחדש ולהקליט את התרחיש ליצירת פרופיל. כדי למנוע בלבול, החלון המקורי של כלי הפיתוח ייקרא 'המופע הראשון של כלי הפיתוח', והחלון שנבדוק את המופע הראשון ייקרא 'המופע השני של כלי הפיתוח'.
במכונה השנייה של כלי הפיתוח, החלונית ביצועים – שתיקרא חלונית Perf מכאן ואילך – מתייחסת למופע הראשון של כלי הפיתוח שיוצר מחדש את התרחיש, שטוען פרופיל.
במופע השני של כלי הפיתוח מתחילה הקלטה בזמן אמת, ובמופע הראשון, פרופיל נטען מקובץ בדיסק. המערכת טוענת קובץ גדול כדי ליצור פרופיל מדויק של ביצועי עיבוד קלט גדול. כששתי המכונות מסתיימות, נתוני הפרופיילינג של הביצועים (בדרך כלל נקרא מעקב) מופיעים במופע השני של כלי הפיתוח בחלונית הביצועים שטוענת פרופיל.
המצב הראשוני: זיהוי הזדמנויות לשיפור
אחרי שהטעינה הסתיימה, בצילום המסך הבא ראינו את הדבר הבא במופע של חלונית הביצועים השנייה. להתמקד בפעילות של ה-thread הראשי, שמוצגת מתחת לטראק ראשי. אפשר לראות שיש חמש קבוצות גדולות של פעילות בתרשים הלהבות. אלה כוללים את המשימות שבהן הטעינה נמשכת הכי הרבה זמן. הזמן הכולל של המשימות האלה היה 10 שניות. בצילום המסך הבא, חלונית הביצועים משמשת להתמקדות בכל אחת מקבוצות הפעילות האלה כדי לראות מה אפשר למצוא.
קבוצת פעילות ראשונה: עבודה מיותרת
התברר שקבוצת הפעילות הראשונה הייתה קוד מדור קודם שעדיין פועל, אך לא היה בה צורך אמיתי. בעיקרון, כל מה שמופיע מתחת לקטע הירוק שמסומן בתווית processThreadEvents
כבר היה מיותר. זה היה ניצחון מהיר. הסרת ההפעלה של הפונקציה חסכה כ-1.5 שניות. נהדר!
קבוצת פעילות שנייה
בקבוצת הפעילות השנייה, הפתרון לא היה פשוט כמו עם הראשונה. הפעולה של buildProfileCalls
נמשכה כ-0.5 שניות, וזו לא הייתה משימה שאפשר להימנע ממנה.
מתוך סקרנות, הפעלנו את האפשרות זיכרון בחלונית הביצועים כדי לבצע בדיקה מעמיקה יותר, וראינו שגם הפעילות של buildProfileCalls
צורכת הרבה זיכרון. כאן אפשר לראות איך תרשים הקו הכחול נע פתאום סביב הזמן שבו buildProfileCalls
ירוץ. מצב כזה מרמז על דליפת זיכרון פוטנציאלית.
כדי לעקוב אחרי החשד הזה, השתמשנו בחלונית הזיכרון (חלונית אחרת בכלי הפיתוח, שונה מחלונית ההזזה של הזיכרון בחלונית הביצועים) כדי לבדוק את העניין. בחלונית הזיכרון, בקטע 'דגימת הקצאה' נבחר סוג הפרופיילינג. הפעולה הזו תיעדה את תמונת המצב של הזיכרון (heap snapshot) לחלונית הביצועים שטוענת את פרופיל המעבד (CPU).
בצילום המסך הבא מוצגת תמונת המצב של הזיכרון שנאסף.
מתמונת המצב של הערימה הזו ראינו שהכיתה Set
צרה זיכרון רב. בבדיקת נקודות הקריאה, גילינו שאנחנו מקצים מאפיינים מסוג Set
שלא לצורך לאובייקטים שנוצרו בנפחים גדולים. העלות הזו הצטמצמה ונהיגה הרבה זיכרון, עד לקריסה הרבה של האפליקציה כשמזינים קלט גדול.
ערכות הן כלי שימושי לאחסון פריטים ייחודיים ומספקות פעולות שמתבססות על הייחודיות של התוכן שלהן, כמו שכפול של מערכי נתונים ומתן שאילתות חיפוש יעילות יותר. יחד עם זאת, התכונות האלה לא היו נחוצות כי הנתונים שאוחסנו הובטחו יהיו ייחודיים מהמקור. לכן לא היה צורך בקבוצות מלכתחילה. כדי לשפר את הקצאת הזיכרון, סוג המאפיין השתנה מSet
למערך פשוט. אחרי החלת השינוי הזה, צולמה עוד תמונת מצב של הערימה (heap snapshot), ונרשמה ירידה בהקצאת הזיכרון. למרות שלא הושגו שיפור משמעותי במהירות בעקבות השינוי הזה, היתרון המשני היה שהאפליקציה קרסה בתדירות נמוכה יותר.
קבוצת פעילות שלישית: שקלול ההשפעות של מבנה הנתונים
החלק השלישי הוא ייחודי: בתרשים הלהבות ניתן לראות שהוא מורכב מעמודות צרות אך גבוהות, שמציינות הפעלות של פונקציות עמוקות ורקורסות עמוקות במקרה זה. בסה"כ, הקטע הזה נמשך כ-1.4 שניות. מבדיקת החלק התחתון של הקטע שלמטה ניתן היה לראות שהרוחב של העמודות האלו נקבע על פי משך הזמן של פונקציה אחת: appendEventAtLevel
, שמרמזת על כך שיכול להיות שזה צוואר בקבוק.
דבר אחד בלט בתוך היישום של הפונקציה appendEventAtLevel
. לכל רשומת נתונים אחת בקלט (הקוד נקרא ה'אירוע'), נוסף פריט למפה שעוקבת אחרי המיקום האנכי של רשומות ציר הזמן. הפעולה הזו הייתה בעייתית כי כמות הפריטים שאוחסנו הייתה גדולה מאוד. בעזרת מפות Google ניתן לבצע במהירות חיפושים שמבוססים על מפתחות, אבל היתרון הזה לא זמין בחינם. לדוגמה, ככל שמפה גדלה, הוספת נתונים אליה עלולה להיות יקרה עקב גיבוב (hashing). ניתן להבחין בעלות הזו כשמוסיפים למפה כמויות גדולות של פריטים ברצף.
/**
* Adds an event to the flame chart data at a defined vertical level.
*/
function appendEventAtLevel (event, level) {
// ...
const index = data.length;
data.push(event);
this.indexForEventMap.set(event, index);
// ...
}
ניסינו גישה אחרת שלא חייבה אותנו להוסיף פריט במפה לכל ערך בתרשים הלהבות. השיפור היה משמעותי ואישר שצוואר הבקבוק אכן היה קשור לתקורה כתוצאה מהוספת כל הנתונים למפה. משך הזמן שבו קבוצת הפעילות נמשכה בין 1.4 שניות בערך ל-200 אלפיות השנייה.
לפני:
אחרי:
קבוצת פעילות רביעית: דחייה של נתונים לא קריטיים של עבודה ומטמון כדי למנוע כפילויות
באמצעות התקרבות לחלון זה, ניתן לראות שיש שני בלוקים כמעט זהים של קריאות לפונקציות. אם תבחנו את שמות הפונקציות שנקראות, תוכלו להסיק שהבלוקים האלו מכילים קוד של עצים בונים (לדוגמה, עם שמות כמו refreshTree
או buildChildren
). למעשה, הקוד הקשור הוא הקוד שיוצר את תצוגות העץ בחלונית ההזזה התחתונה של החלונית. העובדה המעניינת היא שתצוגות העץ האלה לא מוצגות מיד לאחר הטעינה. במקום זאת, המשתמש צריך לבחור תצוגת עץ (הכרטיסיות 'מלמטה למעלה', 'עץ שיחות' ו'יומן אירועים' שבחלונית ההזזה) כדי שהעצים יוצגו. כמו כן, ניתן לראות בצילום המסך שתהליך בניית העצים בוצע פעמיים.
זיהינו שתי בעיות בתמונה הזו:
- משימה לא קריטית מנעה את הביצועים של זמן הטעינה. המשתמשים לא תמיד צריכים את הפלט שלו. לכן המשימה לא חיונית לטעינת הפרופיל.
- התוצאה של המשימות האלה לא נשמרה במטמון. זו הסיבה לכך שהעצים חושבו פעמיים, למרות שהנתונים לא השתנו.
התחלנו לדחות את חישוב העץ למועד שבו המשתמש פתח את תצוגת העץ באופן ידני. רק לאחר מכן כדאי לשלם את המחיר של יצירת העצים האלו. הזמן הכולל של הרצת שתי התכונות האלה היה כ-3.4 שניות, כך שעיכוב הטעינה השפיע באופן משמעותי על זמן הטעינה. אנחנו עדיין בודקים גם את סוגי המשימות האלה במטמון.
קבוצת פעילות חמישית: כשאפשר, נמנעים מהיררכיות מורכבות של שיחות
כשבוחנים את הקבוצה לעומק, היה ברור שהמערכת מפעילה שרשרת שיחות מסוימת שוב ושוב. אותו דפוס הופיע 6 פעמים במקומות שונים בתרשים הלהבות, ומשך הזמן הכולל של החלון הזה היה כ-2.4 שניות!
הקוד הקשור שמופעל מספר פעמים הוא החלק שמעבד את הנתונים כדי לעבד ב'מיני מפה' (הסקירה הכללית של הפעילות בציר הזמן תופיע בחלק העליון של החלונית). לא היה ברור למה זה קרה כמה פעמים, אבל זה בהחלט לא חייב לקרות 6 פעמים! למעשה, הפלט של הקוד אמור להישאר עדכני אם לא נטען פרופיל אחר. בתיאוריה, הקוד צריך לפעול רק פעם אחת.
לאחר חקירה, התגלה שהקוד הקשור נקרא כתוצאה מחלקים מרובים בצינור עיבוד הנתונים של הטעינה מפעילים באופן ישיר או עקיף את הפונקציה שמחשבת את המיני-מפה. הסיבה לכך היא שהמורכבות של תרשים השיחות של התוכנה התפתחה עם הזמן, ויחסי תלות נוספים לקוד הזה נוספו ללא כוונה. אין פתרון מהיר לבעיה הזו. הדרך לפתור את הבעיה תלויה בארכיטקטורה של ה-codebase הרלוונטי. במקרה שלנו, נאלצנו להפחית קצת את המורכבות של היררכיית הקריאות ולהוסיף בדיקה כדי למנוע את הפעלת הקוד אם נתוני הקלט לא השתנו. לאחר היישום של השינוי הזה, קיבלנו את התחזית הבאה לגבי ציר הזמן:
הערה: ביצוע של רינדור מיני-מיפוי מתבצע פעמיים, לא פעם אחת. הסיבה לכך היא שיש רישום של שתי מפות קטנות לכל פרופיל: אחת לסקירה הכללית בחלק העליון של החלונית והשנייה לתפריט הנפתח שבוחר את הפרופיל הגלוי כרגע מההיסטוריה (כל פריט בתפריט זה מכיל סקירה כללית של הפרופיל שהוא בוחר). עם זאת, בשני המקומות האלה יש את אותו תוכן, כך שאפשר לעשות שימוש חוזר בשניהם.
מאחר שהמיני-מפות האלה הן תמונות שצוירו על בד ציור, צריך היה להשתמש בכלי העזר של drawImage
ליצירת קנבס, ולאחר מכן להריץ את הקוד פעם אחת בלבד כדי לחסוך זמן נוסף. כתוצאה ממאמץ זה, משך הזמן של הקבוצה קוצר מ-2.4 שניות ל-140 אלפיות השנייה.
סיכום
לאחר שיישמנו את כל התיקונים האלה (ועוד כמה התיקונים קטנים יותר פה ושם), השינוי בציר הזמן של טעינת הפרופיל נראה כך:
לפני:
אחרי:
זמן הטעינה אחרי השיפורים היה 2 שניות. המשמעות היא ששיפור של כ-80% הושג תוך מאמץ נמוך יחסית, כי רוב הביצוע היה תיקונים מהירים. כמובן שהמפתח היה לזהות בצורה נכונה מה לעשות בהתחלה, וחלונית הביצועים הייתה הכלי המתאים לכך.
חשוב גם להדגיש שהמספרים האלה ספציפיים לפרופיל שמשמש כנושא מחקר. הפרופיל הזה עניין אותנו כי הוא היה גדול במיוחד. עם זאת, מכיוון שצינור עיבוד הנתונים זהה לכל פרופיל, השיפור המשמעותי שהושג חל על כל פרופיל שנטען בחלונית הביצועים.
חטיפות דסקית
יש כמה שיעורים שכדאי ללמוד מהתוצאות האלה כשמדובר באופטימיזציית ביצועים של האפליקציה:
1. להשתמש בכלי פרופיילינג כדי לזהות דפוסי ביצועים של סביבת זמן הריצה
כלים ליצירת פרופילים עוזרים מאוד להבין מה קורה באפליקציה בזמן שהיא פועלת, במיוחד כדי לזהות הזדמנויות לשיפור הביצועים. חלונית הביצועים בכלי הפיתוח ל-Chrome היא אפשרות מצוינת לאפליקציות אינטרנט, כי זהו הכלי המקורי ליצירת פרופילים של דפי אינטרנט בדפדפן, והוא מתוחזק באופן פעיל כדי להתעדכן בתכונות החדשות ביותר של פלטפורמת האינטרנט. בנוסף, עכשיו הוא הרבה יותר מהיר! 😉
משתמשים בדוגמאות שיכולות לשמש כעומסי עבודה מייצגים, ובודקים מה אפשר למצוא.
2. נמנעים מהיררכיות מורכבות של שיחות
מומלץ לא להרכיב יותר מדי את גרף השיחות. היררכיות מורכבות של שיחות מאפשרות לכם להציג בקלות רגרסיות של ביצועים, וקשה להבין למה הקוד פועל כמו שצריך, וכך קשה יותר להשיג שיפורים.
3. זיהוי עבודה מיותרת
בדרך כלל מסדי קוד מזדקנים מכילים קוד שכבר לא נחוץ. במקרה שלנו, קוד ישן ולא נחוץ תופס חלק משמעותי מזמן הטעינה הכולל. ההסרה שלו הייתה הפרי הכי נמוך.
4. שימוש נכון במבני נתונים
השתמשו במבני נתונים כדי לשפר את הביצועים, אבל בדקו גם את העלויות והחסרונות של כל סוג של מבנה נתונים כשאתם מחליטים באילו מבני נתונים להשתמש. מדובר לא רק במורכבות המרחב של מבנה הנתונים עצמו, אלא גם במורכבות הזמן של הפעולות הרלוונטיות.
5. כדאי לשמור את התוצאות במטמון כדי למנוע עבודה כפולה בשביל פעולות מורכבות או שחוזרות על עצמן
אם הביצוע של הפעולה יקר, כדאי לשמור את התוצאות שלה בפעם הבאה שיש בה צורך. הגיוני גם לעשות זאת אם הפעולה מתבצעת פעמים רבות – גם אם כל פעם בנפרד לא יקרה במיוחד.
6. דחייה של עבודות לא קריטיות
אם הפלט של המשימה לא נדרש באופן מיידי וביצוע המשימה מרחיב את הנתיב הקריטי, כדאי לדחות אותה על ידי קריאה מדורגת כשהפלט שלה נחוץ בפועל.
7. להשתמש באלגוריתמים יעילים לקלט גדול
כשמדובר על קלטים גדולים, חשוב להקפיד על אלגוריתמים של מורכבות זמן אופטימלית. לא בדקנו את הקטגוריה הזו בדוגמה הזו, אבל קשה להמעיט בחשיבותה.
8. בונוס: בצעו השוואה בין צינורות עיבוד הנתונים שלכם
כדי לוודא שהקוד המתפתח שלכם נשאר מהיר, מומלץ לעקוב אחר ההתנהגות ולהשוות אותה לתקנים. כך ניתן לזהות רגרסיות באופן יזום ולשפר את האמינות הכוללת, כדי להצליח בטווח הארוך.