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

Chris Wilson
Chris Wilson

מבוא

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

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

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

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

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

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

אז קדימה, לטיפים על V8!

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

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

לכן

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

iWork Numbers

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) יש שני מהדרים שונים של Just In-Time (JIT):

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

המהודר המלא

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

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

לכן

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

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

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

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

הכלי שמבצע אופטימיזציה

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

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

אפשר לרשום ביומן מה עבר אופטימיזציה באמצעות ה-"d8" הנפרד של מנוע V8:

d8 --trace-opt primes.js

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

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

לכן

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

Try { perf_sensitive() } catch (e) { // טפל בחריגים כאן } ```

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

d8 --trace-opt primes.js

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

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

לכן

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

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

קובצי עזר