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

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

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

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

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

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

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