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

Chris Wilson
Chris Wilson

מבוא

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

העצה הכי חשובה

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

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

  • התכוננו מראש למקרה שתיתקלו בבעיה
  • אחרי זה, מזהים ומבינים את מהות הבעיה
  • ולבסוף, מתקנים את מה שחשוב

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

אז הנה הטיפים ל-V8:

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

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

עד שמוסיפים למופע האובייקט p2 את האיבר הנוסף ".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 ביט.

מערכים

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

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

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

לכן

  • שימוש במפתחות רציפים שמתחילים ב-0 למערכים
  • אל תקצו מראש מערכים גדולים (למשל, יותר מ-64,000 רכיבים) לגודל המקסימלי שלהם, אלא הגדילו אותם תוך כדי עבודה.
  • אל תמחקו רכיבים במערכים, במיוחד במערכים מספריים
  • אל תטענו רכיבים שלא אותחלו או שנמחקו:
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.
}

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

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

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

קומפילציה של JavaScript

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

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

הקומפיילר המלא

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

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

לכן

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

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

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

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

הקומפיילר לאופטימיזציה

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

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

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

d8 --trace-opt primes.js

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

עם זאת, לא את כל הפונקציות אפשר לשפר – תכונות מסוימות מונעות מהמהדר לבצע אופטימיזציה של פונקציה מסוימת (bail-out). באופן ספציפי, המהדר הממטב לא פועל כרגע על פונקציות עם בלוקים של try {} catch {}‎.

לכן

  • אם יש לכם בלוקים של try {} catch {}, שימו קוד שרגיש לביצועים בפונקציה בתצוגת עץ: ```js function perf_sensitive() { // Do performance-sensitive work here }

try { perf_sensitive() } catch (e) { // Handle exceptions here } ```

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

קובצי עזר