חלונית ביצועים מהירה יותר ב-400% באמצעות תפיסה

Andrés Olivares
Andrés Olivares
Nancy Li
Nancy Li

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

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

הגדרה ויצירה מחדש של תרחיש הפרופיילינג שלנו

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

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

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

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

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

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

המצב הראשוני: זיהוי הזדמנויות לשיפור

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

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

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

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

קבוצת פעילות שנייה

בקבוצת הפעילות השנייה, הפתרון לא היה פשוט כמו עם הראשונה. הפעולה של buildProfileCalls נמשכה כ-0.5 שניות, וזו לא הייתה משימה שאפשר להימנע ממנה.

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

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

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

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

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

בצילום המסך הבא מוצגת תמונת המצב של הזיכרון שנאסף.

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

מתמונת המצב של הערימה הזו ראינו שהכיתה 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 אלפיות השנייה.

לפני:

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

אחרי:

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

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

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

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

זיהינו שתי בעיות בתמונה הזו:

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

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

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

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

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

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

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

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

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

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

סיכום

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

לפני:

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

אחרי:

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

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

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

חטיפות דסקית

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

1. להשתמש בכלי פרופיילינג כדי לזהות דפוסי ביצועים של סביבת זמן הריצה

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

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

2. נמנעים מהיררכיות מורכבות של שיחות

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

3. זיהוי עבודה מיותרת

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

4. שימוש נכון במבני נתונים

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

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

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

6. דחייה של עבודות לא קריטיות

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

7. להשתמש באלגוריתמים יעילים לקלט גדול

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

8. בונוס: בצעו השוואה בין צינורות עיבוד הנתונים שלכם

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