איך אפשר להאיץ פי 10 את דוחות הקריסות בכלי הפיתוח ל-Chrome

Benedikt Meurer
Benedikt Meurer

מפתחי אתרים רגילים להשפעה קטנה או אפסית על הביצועים כשהם מאתרים באגים בקוד. עם זאת, הציפייה הזו לא קיימת בכל המקרים. מפתח C++‏++‎ לעולם לא מצפה שגרסת build לניפוי באגים של האפליקציה שלו תגיע לביצועים של ייצור, ובשנים הראשונות של Chrome, פתיחת DevTools גרמה להשפעה משמעותית על ביצועי הדף.

העובדה שהירידה בביצועים הזו כבר לא מורגשת היא תוצאה של שנים של השקעה ביכולות ניפוי הבאגים של DevTools ושל V8. עם זאת, אף פעם לא נוכל לצמצם את נטל הביצועים של DevTools לאפס. הגדרת נקודות עצירה, ניתוח הקוד, איסוף נתוני מעקב ב-stack, תיעוד של מעקב ביצועים וכו' – כל הפעולות האלה משפיעות על מהירות הביצועים במידה מסוימת. אחרי הכל, התבוננות במשהו משפיעה עליו.

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

כפי שאפשר לראות בסרטון, ההאטה היא בסדר גודל של 5-10x, וזו בהחלט לא נתון מקובל. השלב הראשון היה להבין לאן כל הזמן הולך ומה גורם להאטה העצומה הזו כשכלי הפיתוח פתוחים. שימוש ב-Linux perf בתהליך ה-Renderer של Chrome חשף את ההתפלגות הבאה של זמן הביצוע הכולל של ה-Renderer:

זמן הביצוע של ה-Renderer ב-Chrome

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

הסקת שם השיטה

מה שהיה מפתיע עוד יותר הוא שרוב הזמן הוקדש לפונקציה JSStackFrame::GetMethodName() ב-V8 – למרות שידענו מבדיקות קודמות ש-JSStackFrame::GetMethodName() היא לא פונקציה זרה בעולם בעיות הביצועים. הפונקציה הזו מנסה לחשב את שם השיטה עבור מסגרות שנחשבות להפעלות של שיטות (מסגרות שמייצגות הפעלות של פונקציות בפורמט obj.func() ולא func()). מבט מהיר בקוד גילה שהיא פועלת על ידי ביצוע סריקה מלאה של האובייקט ורשת האב טיפוס שלו, ומחפשת את

  1. נכסי נתונים שה-value שלהם הוא הסגירה func, או
  2. מאפייני גישה שבהם get או set שווים לסגירה של func.

זה לא נשמע זול במיוחד, אבל גם לא נשמע שזה יכול להסביר את ההאטה הנוראה הזו. לכן התחלנו לבדוק את הדוגמה שדווחה ב-chromium:1069425, וגילינו שהמעקב אחר סטאק נאסף למשימות אסינכררוניות וגם להודעות ביומן שמקורן ב-classes.js – קובץ JavaScript בנפח 10MB. בדיקה מעמיקה יותר העלתה שמדובר בעצם בסביבת זמן ריצה של Java עם קוד אפליקציה שעבר הידור ל-JavaScript. נתיבי הסטאק הכילו כמה מסגרות עם שיטות שמופעלות באובייקט A, לכן חשבנו שראוי להבין איזה סוג של אובייקט אנחנו מטפלים בו.

מעקב סטאק של אובייקט

נראה שהמחשבר של Java ל-JavaScript יצר אובייקט אחד עם 82,203 פונקציות – ברור שהעניין התחיל להתעניין. לאחר מכן חזרנו ל-JSStackFrame::GetMethodName() של V8 כדי להבין אם יש שם הזדמנויות פשוטות שאפשר לנצל.

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

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

A.SDV = function() {
   // ...
};

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

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

נראה שזו הזדמנות נוחה, כי כדי לחלץ את השמות צריך לעבור על כל הנכסים. במקום לבצע את שתי הפעמים – O(N) לחילוץ השם ו-O(N log(N)) לבדיקות – אפשר לבצע את הכל בפעולה אחת ולבדוק ישירות את ערכי הנכסים. הפעולה הזו הפכה את הפונקציה כולה למהירה יותר פי 2 עד 10.

הממצא השני היה מעניין עוד יותר. מבחינה טכנית, הפונקציות היו פונקציות אנונימיות, אבל מנוע V8 הקליט בשבילן את מה שאנחנו מכנים שם משוערים. בפונקציות לינאריות שמופיעות בצד שמאל של הקצאות בפורמט obj.foo = function() {...}, מנתח V8 שומר בזיכרון את "obj.foo" בתור השם המשוער של הפונקציה הלינרית. במקרה שלנו, פירוש הדבר הוא שלא היה לנו את השם הנכון שאפשר פשוט לחפש, אבל היה לנו משהו שדומה לו מספיק: בדוגמה של A.SDV = function() {...} שלמעלה, השם המשוער היה "A.SDV", ויכולנו להסיק את שם המאפיין מהשם המשוער על ידי חיפוש הנקודה האחרונה, ואז לחפש את המאפיין "SDV" באובייקט. הפעולה הזו פתרה את הבעיה כמעט בכל המקרים, והחליפה סריקה מלאה ויקרה בחיפוש נכס יחיד. שני השיפורים האלה נוספו כחלק מCL הזה, והם צמצמו באופן משמעותי את ההאטה בדוגמה שדווחה ב-chromium:1069425.

Error.stack

יכולנו לסיים את השיחה כאן. אבל היה משהו חשוד, כי כלי הפיתוח אף פעם לא משתמשים בשם השיטה למסגרות הסטאק. למעשה, הקלאס v8::StackFrame ב-API של C++‎ לא חושף אפילו דרך להגיע לשם method. לכן נראה לנו לא נכון שנגיע לכך מלכתחילה.JSStackFrame::GetMethodName() במקום זאת, המקום היחיד שבו אנחנו משתמשים בשם method (ומציגים אותו) הוא ב-JavaScript stack trace API. כדי להבין את השימוש הזה, נבחן את הדוגמה הפשוטה הבאה error-methodname.js:

function foo() {
    console.log((new Error).stack);
}

var object = {bar: foo};
object.bar();

כאן יש פונקציה foo שמותקנת בשם "bar" ב-object. הפעלת קטע הקוד הזה ב-Chromium מניבה את הפלט הבא:

Error
    at Object.foo [as bar] (error-methodname.js:2)
    at error-methodname.js:6

כאן מוצג חיפוש שם השיטה: מסגרת הערימה העליונה מוצגת כדי להפעיל את הפונקציה foo במכונה של Object באמצעות השיטה bar. לכן, בנכס error.stack הלא סטנדרטי נעשה שימוש נרחב ב-JSStackFrame::GetMethodName(), ובדיקות הביצועים שלנו מצביעות גם על כך שהשינויים שלנו הובילו לשיפור משמעותי במהירות.

שיפור המהירות בנקודות השוואה מיקרו של StackTrace

אבל חזרה לנושא Chrome DevTools, העובדה ששם השיטה מחושבת למרות שלא נעשה שימוש ב-error.stack לא נראית נכונה. יש כאן קצת היסטוריה שתעזור לנו: באופן מסורתי, ל-V8 היו שני מנגנונים נפרדים לאיסוף ולתצוגה של נתיב סטאק לשני ממשקי ה-API השונים שמפורטים למעלה (v8::StackFrame API ל-C++ ו-JavaScript stack trace API). שתי דרכים שונות לביצוע (פחות או יותר) אותה פעולה היו חשופות לשגיאות ולעיתים קרובות הובילו לאי-עקביות ולבאגים. לכן, בסוף שנת 2018 התחלנו פרויקט שמטרתו להגיע לצוואר בקבוק יחיד לתיעוד של נתיב סטאק.

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

באופן כללי, הפעולה הזו משפרת את הביצועים, אבל לצערנו התברר שהיא מנוגדת במידה מסוימת לאופן שבו אובייקטים של ממשקי API ב-C++ משמשים ב-Chromium וב-DevTools. במיוחד מאחר שהוספנו את הכיתה החדשה v8::internal::StackFrameInfo, שמכילה את כל המידע על מסגרת סטאק שנחשפה דרך v8::StackFrame או דרך error.stack, תמיד היינו מחשבים את קבוצת-העל של המידע שסופק על ידי שני ממשקי ה-API. המשמעות היא שבשימוש ב-v8::StackFrame (ובמיוחד ב-DevTools), היינו מחשבים גם את שם השיטה ברגע שמתבצעת בקשה למידע על מסגרת סטאק. מסתבר ש-DevTools תמיד מבקש מיד מידע על המקור ועל הסקריפט.

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

שמות פונקציות

אחרי שסיימנו את שינויי הקוד שצוינו למעלה, התקורה של הסימולציה (הזמן שחלף ב-v8_inspector::V8Debugger::symbolize) צומצמה ל-15% מזמן הביצוע הכולל, ויכולנו לראות בבירור יותר איפה V8 משקיע זמן בזמן (איסוף ו)סימולציה של מסגרות סטאק לשימוש ב-DevTools.

עלות הסימון

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

הממצא המעניין יותר מבחינתנו היה ש-v8::StackFrame::GetFunctionName היה גבוה באופן מפתיע בכל הפרופילים שבדקנו. כשבדקנו לעומק, הבנו שהחישוב של השם שאנחנו מציגים לפונקציה ב-DevTools בפריים של הסטאק היה יקר מדי ולא הכרחי.

  1. קודם מחפשים את הנכס הלא סטנדרטי "displayName", ואם הוא מניב נכס נתונים עם ערך מחרוזת, משתמשים בו,
  2. אחרת, חוזרים לחיפוש נכס "name" רגיל ובודקים שוב אם הוא מניב נכס נתונים שהערך שלו הוא מחרוזת,
  3. ובסופו של דבר חוזרים לשם ניפוי באגים פנימי שמחושב על ידי מנתח ה-V8 ונשמר בביטוי הליניארי של הפונקציה.

המאפיין "displayName" נוסף כפתרון זמני לבעיה של מאפיין "name" במופעי Function שהם לקריאה בלבד ולא ניתנים להגדרה ב-JavaScript, אבל הוא מעולם לא הוגדר כתקן ולא היה בשימוש נרחב, כי הכלים למפתחים בדפדפן הוסיפו הסקת שם פונקציה שמבצעת את המשימה ב-99.9% מהמקרים. בנוסף, ב-ES2015 אפשר להגדיר את הנכס "name" במכונות Function, וכך לבטל לחלוטין את הצורך בנכס "displayName" מיוחד. מאחר שהחיפוש השלילי של "displayName" יקר למדי ולא ממש הכרחי (ES2015 שוחרר לפני יותר מחמש שנים), החלטנו להסיר את התמיכה במאפיין הלא סטנדרטי fn.displayName מ-V8 (ומ-DevTools).

עכשיו, כשהחיפוש השלילי של "displayName" הוסרה, מחצית מהעלות של v8::StackFrame::GetFunctionName הוסרה. החצי השני מועבר לחיפוש הגנרי של נכס "name". למזלנו, כבר הייתה לנו לוגיקה מסוימת כדי להימנע מחיפושים יקרים של נכס "name" במכונות Function (שלא נוגעו בהן), שהוסיפה בגרסה 8 לפני זמן מה כדי לזרז את Function.prototype.bind() עצמה. הועברנו את הבדיקות הנדרשות, שמאפשרות לנו לדלג על החיפוש הגנרי היקר מלכתחילה. כתוצאה מכך, v8::StackFrame::GetFunctionName לא מופיע יותר בפרופילים שבדקנו.

סיכום

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

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

הורדת הערוצים לתצוגה מקדימה

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

יצירת קשר עם צוות כלי הפיתוח ל-Chrome

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