טיפים לשיפור הביצועים ב-JavaScript ב-V8

Chris Wilson
Chris Wilson

מבוא

דניאל קליפורד נאם הרצאה מצוינת ב-Google I/O בנושא טיפים וטריקים לשיפור הביצועים של JavaScript בגרסה V8. דניאל עודד אותנו "לדרוש מהר יותר" - כדי לנתח בקפידה את ההבדלים בביצועים בין C++ ל-JavaScript, ולכתוב קוד באופן מודע לאופן הפעולה של JavaScript. מאמר זה כולל סיכום של הנקודות החשובות ביותר בהרצאה של דניאל, ונעדכן את המאמר גם בהתאם לשינויים בהנחיות הביצועים.

העצה החשובה ביותר

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

העצה הבסיסית ביותר להשגת ביצועים טובים ביישומי אינטרנט היא:

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

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

אז נעבור לטיפים של V8!

כיתות מוסתרות

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

למשל:

function Point(x, y) {
  this.x = x;
  this.y = y;
}

var p1 = new Point(11, 22);
var p2 = new Point(33, 44);
// At this point, p1 and p2 have a shared hidden class
p2.z = 55;
// warning! p1 and p2 now have different hidden classes!```

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

לכן

  • מאתחלים את כל חברי האובייקטים בפונקציות של בנאים (כדי שהמכונות לא ישנו את הסוג מאוחר יותר)
  • אתחול חברי האובייקט תמיד באותו סדר

מספרים

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

למשל:

var i = 42;  // this is a 31-bit signed integer
var j = 4.2;  // this is a double-precision floating point number```

לכן

  • עדיף להשתמש בערכים מספריים שאפשר לייצג כמספרים שלמים חתומים של 31 ביט.

מערכים

כדי לטפל במערכים גדולים ודלילים, יש שני סוגים של אחסון מערך באופן פנימי:

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

מומלץ לא לגרום למערך האחסון של המערך להפוך מסוג אחד לאחר.

לכן

  • שימוש במקשים רציפים שמתחיל ב-0 למערכים
  • אל תקצו מראש מערכים גדולים (למשל אלמנטים של יותר מ-64K) לגודל המקסימלי שלהם, אלא תגדלו תוך כדי תנועה.
  • לא למחוק רכיבים במערכים, במיוחד במערכים מספריים
  • אין לטעון רכיבים שלא מאותחלו או שנמחקו:
for (var b = 0; b < 10; b++) {
  a[0] |= b;  // Oh no!
}
//vs.
a = new Array();
a[0] = 0;
for (var b = 0; b < 10; b++) {
  a[0] |= b;  // Much better! 2x faster.
}

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

var a = new Array();
a[0] = 77;   // Allocates
a[1] = 88;
a[2] = 0.5;   // Allocates, converts
a[3] = true; // Allocates, converts```

פחות יעיל מ:

var a = [77, 88, 0.5, true];

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

  • אתחול באמצעות ליטרל מערכים עבור מערכים קטנים בגודל קבוע
  • הקצה מראש מערכים קטנים (<64k) כדי לתקן את הגודל לפני השימוש בהם
  • לא לשמור ערכים לא מספריים (אובייקטים) במערכים מספריים
  • הקפידו לא לגרום להמרה מחדש של מערכים קטנים אם אתם מאתחלים ללא ליטרלים.

הידור JavaScript

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

  • הידור "מלא", שיכול ליצור קוד טוב לכל JavaScript
  • המהדר באופטימיזציה, שמפיק קוד מצוין עבור רוב ה-JavaScript, אך נדרש זמן רב יותר כדי לבצע את ההידור שלו.

המהדר המלא

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

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

לכן

  • שימוש מונומורפי בפעולות מועדף על פני פעולות פולימורפיות

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

function add(x, y) {
  return x + y;
}

add(1, 2);      // + in add is monomorphic
add("a", "b");  // + in add becomes polymorphic```

מהדר 'ביצוע אופטימיזציה'

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

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

תוכל לתעד את האופטימיזציה באמצעות גרסת d8 העצמאית של מנוע ה-V8:

d8 --trace-opt primes.js

(ביומן נרשמים השמות של הפונקציות שעברו אופטימיזציה ל-stdout).

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

לכן

  • אם ניסית

נסה { perf_sensitive() } take (e) { // טיפול בחריגים כאן } ```

סביר להניח שההנחיה הזו תשתנה בעתיד, מכיוון שנפעיל בלוקים test/catch במהדר האופטימיזציה. כדי לבחון איך המהדר שמבצע את האופטימיזציה יצאו משימוש בפונקציות, אפשר להשתמש באפשרות ' --trace-opt' עם d8 שלמעלה, וכך לקבל מידע נוסף על הפונקציות שיצאו משימוש:

d8 --trace-opt primes.js

ביטול אופטימיזציה

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

לכן

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

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

d8 --trace-deopt primes.js

כלי V8 אחרים

דרך אגב, ניתן גם להעביר אפשרויות מעקב V8 ל-Chrome בהפעלה:

"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --js-flags="--trace-opt --trace-deopt"```

בנוסף לשימוש בכלים למפתחים, אפשר גם להשתמש ב-d8 כדי לבצע פרופיילינג:

% out/ia32.release/d8 primes.js --prof

הפעולה הזו משתמשת בכלי לניתוח הדגימות המובנה, שלוקח דגימה כל אלפית שנייה וכותב v8.log.

בסיכום

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

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

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

קובצי עזר