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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

בצילום המסך הבא מוצגת קובץ snapshot של אשכול שנאסף.

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

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

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

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

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

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

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

/**
 * 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. התוצאה של המשימות האלה לא נשמרה במטמון. לכן, עצי ה-Tree חושבו פעמיים, למרות שהנתונים לא השתנו.

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

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

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

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

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

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

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

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

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

סיכום

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

לפני:

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

אחרי:

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

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

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

חטיפות דסקית

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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