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

John McCutchan
John McCutchan

מבוא

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

סשן ב-Google I/O 2013

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

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

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

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

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

V8 CSI: Oz

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

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

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

הוכחות

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

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

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

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

באיזה סוג של בעיית ביצועים מדובר?

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

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

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

למה מנקה האשפה פועל?

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

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

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

זיכרון צעיר ב-V8

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

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

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

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

חשודים

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

חשוד מס' 1

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

חשוד מס' 2

שינוי 'הצורה' של אובייקט מחוץ למבנה ה-constructor. זה קורה בכל פעם שמתווסף מאפיין חדש לאובייקט מחוץ למבנה ה-constructor. הפעולה הזו יוצרת סיווג מוסתר חדש לאובייקט. כשקוד שעובר אופטימיזציה מזהה את הכיתה החבויה החדשה הזו, מתבצעת ביטול אופטימיזציה (deopt). הקוד שלא עבר אופטימיזציה יבוצע עד שהקוד יסווג כקוד חם ויעבור אופטימיזציה שוב. תהליך ההפסקה וההפעלה מחדש של האופטימיזציה יביא לתנודות חדות בביצועים,אבל אין לו קורלציה ישירה ליצירת אשפה מוגזמת. לאחר בדיקה קפדנית של הקוד, אושר שהצורות של האובייקטים היו סטטיות, ולכן חשוד מס' 2 נפסל.

חשוד מס' 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 חדש, ומקרב אותנו להשהיה של איסוף האשפה.

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

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

זיהוי פלילי

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

ניסוי מס' 1

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

מתחילים מכך ש-Chrome לא פועל בכלל, ומפעילים את Chrome עם הדגלים הבאים:

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

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

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

אחרי שמבצעים build של גרסה 8, אפשר לעבד את היומן באמצעות מעבד ה-tick:

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

בדקנו את הפלט של מעבד ה-tick מקוד המקור של 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 היטב, ולכן זיהו מיד שלפעמים V8 לא מבצע אופטימיזציה של מבנה הלולאה for-i-in. במילים אחרות, אם פונקציה מכילה מבנה של לולאה 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, וכתוצאה מכך ההפסקות לצורך איסוף אשפה יהיו פחות תכופות. כדי לוודא זאת, תוכלו לבצע את אותם ניסויים עם קוד חדש. הקורא הקפדן ישים לב שמספרים כפולים עדיין נשמרים כמאפיינים. אם ניתוח הפרופיל מראה שזה כדאי, שינוי המיקום למערך של מספרים כפולים או למערך של נתונים מסוגים שונים יפחית עוד יותר את מספר האובייקטים שנוצרים.

חותמת

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

צאו לדרך ותחילו לפתור פשעים בביצועים!