שימוש בזיהוי פלילי ובעבודת חקירה כדי לפתור תעלומות ביצועים של JavaScript

John McCutchan
John McCutchan

מבוא

בשנים האחרונות, אפליקציות אינטרנט הואצו באופן משמעותי. הרבה אפליקציות פועלות עכשיו מספיק מהר, עד ששמעתי כמה מפתחים תוהים בקול רם "האם האינטרנט מהיר מספיק?". לאפליקציות מסוימות זה אולי מספיק, אבל למפתחים שעובדים על אפליקציות עם ביצועים גבוהים, אנחנו יודעים שזה לא מספיק מהר. למרות ההתקדמות המדהימה בטכנולוגיית המכונה הווירטואלית של JavaScript, מחקר שנערך לאחרונה הראה שאפליקציות של Google מבלות בין 50% ל-70% מהזמן שלהן בתוך V8. לאפליקציה יש זמן מוגבל, וצמצום מחזורים במערכת אחת מאפשר למערכת אחרת לעשות יותר. חשוב לזכור: לאפליקציות שפועלות בקצב של 60 פריימים לשנייה יש רק 16 אלפיות השנייה לכל פריים, אחרת – האפליקציה תיתקע. במאמר הזה, שמבוסס על סיפור אמיתי, תוכלו לקרוא על אופטימיזציה של JavaScript ועל פרופילים של אפליקציות JavaScript. הסיפור עוסק בבלשי הביצועים של צוות V8, שמתחקים אחר בעיית ביצועים לא ברורה ב-Find Your Way to Oz.

סשן Google I/O 2013

הצגתי את החומר הזה בכנס Google I/O בשנת 2013. כדאי לצפות בסרטון הבא:

למה הביצועים חשובים?

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

פתרון בעיות בביצועים

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

V8 CSI: Oz

הקוסמים המדהימים שבנו את Find Your Way to Oz פנו לצוות V8 עם בעיית ביצועים שהם לא הצליחו לפתור בעצמם. לפעמים Oz קופא, וזה גורם לבעיות בביצועים. המפתחים של Oz ביצעו חקירה ראשונית באמצעות חלונית ציר הזמן בכלי הפיתוח ל-Chrome. הם בדקו את השימוש בזיכרון ונתקלו בתרשים שיני המסור המפחיד. פעם בשנייה אסף איסוף האשפה 10 מגה-בייט של אשפה והפסקות איסוף האשפה תאמו את ה"ג'נק". בדומה לצילום המסך הבא מציר הזמן בכלי הפיתוח ל-Chrome:

ציר הזמן של כלי הפיתוח

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

הוכחות

השלב הראשון הוא לאסוף ולבחון את הראיות הראשוניות.

איזה סוג של אפליקציה אנחנו בודקים?

ההדגמה של Oz היא אפליקציה תלת-ממדית אינטראקטיבית. לכן, הוא רגיש מאוד להפסקות שנגרמות על ידי איסוף אשפה. חשוב לזכור: אפליקציה אינטראקטיבית שפועלת בקצב של 60fps צריכה 16ms כדי לבצע את כל פעולות ה-JavaScript, וצריך להשאיר חלק מהזמן הזה ל-Chrome כדי לעבד את הקריאות לגרפיקה ולשרטט את המסך.

ב-Oz מתבצעים הרבה חישובים אריתמטיים של ערכים כפולים, ומתבצעות קריאות תכופות ל-WebAudio ול-WebGL.

באיזה סוג של בעיית ביצועים נתקלת?

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

האם המפתחים פועלים לפי השיטות המומלצות?

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

למה מנגנון איסוף הזבל פועל?

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

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

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

זיכרון צעיר של V8

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

באופן אינטואיטיבי, אתם צריכים להבין שבכל פעם שאובייקט מוקצה באופן משתמע או מפורש (באמצעות קריאה ל-new,‏ [] או {}), האפליקציה שלכם מתקרבת יותר ויותר לאיסוף אשפה ולהשהיה המפחידה של האפליקציה.

האם צפויים 10MB/sec של נתונים מיותרים באפליקציה הזו?

בקיצור, לא. המפתח לא עושה שום דבר כדי לצפות ל-10MB/sec של.

חשודים

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

חשוד מס' 1

קריאה ל-new במהלך הפריים. חשוב לזכור שכל אובייקט שמוקצה מקרב אתכם להשהיית GC. באפליקציות שפועלות בקצב פריימים גבוה, חשוב במיוחד לשאוף לאפס הקצאות לכל פריים. בדרך כלל, לשם כך נדרשת מערכת מיחזור אובייקטים שתוכננה בקפידה וספציפית לאפליקציה. הבלשים של V8 בדקו עם הצוות של Oz והם לא קראו ל-new. למעשה, הצוות של Oz כבר היה מודע לדרישה הזו ואמר "זה יהיה מביך". אפשר למחוק את זה מהרשימה.

חשוד מס' 2

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

חשוד מס' 3

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

var a = p * d;
var b = c + 3;
var c = 3.3 * dt;
point.x = a * b * c;

התוצאה היא יצירה של 5 אובייקטים מסוג HeapNumber. שלושת הראשונים הם למשתנים, a,‏ b ו-c. הפעולה הרביעית היא לערך האנונימי (a * b) והפעולה החמישית היא מ-‎ #4 * c. הפעולה החמישית מוקצית בסופו של דבר ל-point.x.

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

חשוד מס' 4

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

sprite.position.x += 0.5 * (dt);

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

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

חשוד מספר 4 הוא אפשרות.

זיהוי פלילי

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

ניסוי מס' 1

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

כשהדפדפן Chrome לא פועל, מפעילים אותו עם הדגלים:

--no-sandbox --js-flags="--prof --noprof-lazy --log-timer-events"

ואז סגירה מלאה של Chrome תגרום ליצירת קובץ יומן v8.log בספרייה הנוכחית.

כדי לפרש את התוכן של v8.log, צריך להוריד את אותה גרסה של v8 שבה Chrome משתמש (אפשר לבדוק ב-about:version) ולבנות אותה.

אחרי שיוצרים את v8, אפשר לעבד את היומן באמצעות מעבד הטיקים:

$ tools/linux-tick-processor /path/to/v8.log

(מחליפים את linux ב-mac או ב-windows, בהתאם לפלטפורמה). (צריך להריץ את הכלי הזה מספריית קובצי המקור ברמה העליונה ב-V8).

מעבד הטיקים מציג טבלה מבוססת טקסט של פונקציות JavaScript עם הכי הרבה טיקים:

[JavaScript]:
ticks  total  nonlib   name
167   61.2%   61.2%  LazyCompile: *opt demo.js:12
 40   14.7%   14.7%  LazyCompile: unopt demo.js:20
 15    5.5%    5.5%  Stub: KeyedLoadElementStub
 13    4.8%    4.8%  Stub: BinaryOpStub_MUL_Alloc_Number+Smi
  6    2.2%    2.2%  Stub: BinaryOpStub_ADD_OverwriteRight_Number+Number
  4    1.5%    1.5%  Stub: KeyedStoreElementStub
  4    1.5%    1.5%  KeyedLoadIC:  {12}
  2    0.7%    0.7%  KeyedStoreIC:  {13}
  1    0.4%    0.4%  LazyCompile: ~main demo.js:30

אפשר לראות שבקובץ demo.js היו שלוש פונקציות: opt,‏ unopt ו-main. לצד השמות של פונקציות שעברו אופטימיזציה מופיעה כוכבית (*). שימו לב שהפונקציה opt עברה אופטימיזציה והפונקציה unopt לא עברה אופטימיזציה.

כלי חשוב נוסף בארגז הכלים של הבלש של V8 הוא plot-timer-event. אפשר להפעיל אותו כך:

$ tools/plot-timer-event /path/to/v8.log

אחרי ההרצה, קובץ PNG בשם timer-events.png נמצא בספרייה הנוכחית. כשפותחים אותו, אמור להופיע משהו שנראה כך:

אירועי טיימר

חוץ מהתרשים שבתחתית, הנתונים מוצגים בשורות. ציר ה-X הוא הזמן (באלפיות השנייה). בצד ימין יש תוויות לכל שורה:

ציר ה-Y של אירועי הטיימר

בשורה V8.Execute משורטט קו אנכי שחור בכל תקתוק של הפרופיל שבו V8 הריץ קוד JavaScript. ב-V8.GCScavenger משורטט קו אנכי כחול בכל תקתוק של הפרופיל שבו V8 ביצע איסוף של דור חדש. אותו דבר לגבי שאר המצבים של V8.

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

סוג הקוד שמופעל

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

אם הגעתם לשלב הזה, כדאי לציין שאתם יכולים לעבוד הרבה יותר מהר על ידי ארגון הקוד מחדש של האפליקציה, כך שהיא תוכל לפעול במעטפת ניפוי הבאגים של V8‏: d8. שימוש ב-d8 מאפשר זמני איטרציה מהירים יותר עם הכלים tick-processor ו-plot-timer-event. תופעת לוואי נוספת של השימוש ב-d8 היא שקל יותר לבודד את הבעיה בפועל, וכך להפחית את כמות הרעש בנתונים.

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

תרשים של אירועי טיימר

אם תסתכלו מקרוב, תראו שהקווים השחורים שמציינים מתי V8 מריץ קוד JavaScript חסרים בדיוק באותם זמנים של איסוף האשפה מהדור החדש (קווים כחולים). זה מדגים בבירור שבזמן שאוספים אשפה, הסקריפט מושהה.

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

function updateSprites(dt) {
    for (var sprite in sprites) {
        sprite.position.x += 0.5 * dt;
        // 20 more lines of arithmetic computation.
    }
}

הם מכירים את V8 כל כך טוב, שהם זיהו מיד שמבנה הלולאה for-i-in לפעמים לא עובר אופטימיזציה על ידי V8. במילים אחרות, אם פונקציה מכילה מבנה של לולאת for-i-in, יכול להיות שהיא לא תעבור אופטימיזציה. זה מקרה מיוחד היום, וסביר להניח שהוא ישתנה בעתיד, כלומר, יכול להיות שיום אחד V8 ישפר את מבנה הלולאה הזה. אנחנו לא בלשים של V8 ולא מכירים את V8 כמו את כף היד שלנו, אז איך אנחנו יכולים לקבוע למה updateSprites לא עבר אופטימיזציה?

ניסוי מס' 2

הפעלת Chrome עם הדגל הזה:

--js-flags="--trace-deopt --trace-opt-verbose"

מציג יומן מפורט של נתוני אופטימיזציה וביטול אופטימיזציה. נחפש בנתונים את updateSprites ונמצא:

[disabled optimization for updateSprites, reason: ForInStatement is not fast case]

בדיוק כמו שהבלשים שיערו, הסיבה הייתה מבנה הלולאה for-i-in.

הפנייה נסגרה

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

function updateSprite(sprite, dt) {
    sprite.position.x += 0.5 * dt;
    // 20 more lines of arithmetic computation.
}

function updateSprites(dt) {
    for (var sprite in sprites) {
        updateSprite(sprite, dt);
    }
}

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

אפילוג

המפתחים של Oz לא עצרו שם. בעזרת הכלים והשיטות שקיבלו מהבלשים של V8, הם הצליחו למצוא עוד כמה פונקציות שנתקעו ב"גיהנום של ביטול האופטימיזציה", וחילקו את קוד החישוב לפונקציות עלה (leaf functions) שעברו אופטימיזציה. כתוצאה מכך, הביצועים השתפרו עוד יותר.

זה הזמן לצאת לדרך ולפתור את תעלומות הביצועים.