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

Benedikt Meurer
Benedikt Meurer

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

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

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

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

זמן הביצוע של ה-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-10x מהירה יותר.

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

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

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