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

John McCutchan
John McCutchan

מבוא

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

פעילות של Google I/O 2013

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

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

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

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

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

V8 CSI: אוז

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

ציר הזמן של Devtools

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

ראיות

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

על איזה סוג אפליקציה מדובר?

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

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

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

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

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

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

למה אוסף האשפה פועל?

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

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

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

זיכרון הצעיר V8

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

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

האם נדרשים 10MB לשנייה של אשפה באפליקציה הזו?

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

חשוד

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

חשוד מס' 1

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

חשוד מס' 2

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

חשוד מס' 3

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

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

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

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

חשוד מס' 4

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

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

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

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

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

זיהוי פלילי

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

ניסוי מס' 1

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

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

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

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

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

אחרי שתסיימו ליצור את v8, תוכלו לעבד את היומן באמצעות מעבד הסימון:

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

(צריך להחליף את Mac או Windows ב-Linux, בהתאם לפלטפורמה). (יש להפעיל את הכלי מספריית המקור ברמה העליונה בגרסה 8).

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

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

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

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

אירועי טיימר

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

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

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

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

סוג הקוד שמתבצע

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

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

ניסוי מס' 2

הפעלת Chrome עם הסימון הזה:

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

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

[אופטימיזציה מושבתת עבור updateSprites, הסיבה: ForInStatement הוא לא מקרה מהיר]

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

אפילוג

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

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