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

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

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

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

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

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

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

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

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

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

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

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

בסיום הטעינה, ניתן לראות את הפרטים הבאים במופע השני של לוח הביצועים בצילום המסך הבא. מתמקדים בפעילות של ה-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 שניות!

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

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

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

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

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

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

סיכום

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

לפני:

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

אחרי:

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

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

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

חטיפות דסקית

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

8. בונוס: השוואה לשוק

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