מבוא
דניאל קלייפורד (Daniel Clifford) נשא הרצאה מעולה ב-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. ככל שתוכלו להימנע מהפרדה בין הכיתות המוסתרות, כך הביצועים יהיו טובים יותר.
לכן
- מאתחלים את כל חברי האובייקט בפונקציות ה-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 ביט.
מערכים
כדי לטפל במערכים גדולים ודילים, יש שני סוגים של אחסון מערכים באופן פנימי:
- Fast Elements: אחסון ליניארי של קבוצות מפתחות קומפקטיות
- רכיבי מילון: אחסון בטבלת גיבוב במקרים אחרים
מומלץ לא לגרום לאחסון המערך לעבור מסוג אחד לאחר.
לכן
- שימוש במפתחות רצופים שמתחילים ב-0 למערכים
- אל תקצו מראש מערכי נתונים גדולים (למשל, יותר מ-64 אלף רכיבים) לגודל המקסימלי שלהם, אלא הגדילו אותם תוך כדי עבודה
- לא למחוק רכיבים במערכים, במיוחד במערכים מספריים
- לא טוענים רכיבים שנמחקו או לא הופעלו:
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.
}
בנוסף, מערכי מספרים כפולים (doubles) מהירים יותר – הכיתה המוסתרת של המערך עוקבת אחרי סוגי הרכיבים, ומערכי מספרים כפולים בלבד עוברים תהליך של ביטול אריזה (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]
גורמת להמרת המערך למערך של מספרים כפולים ללא אריזה, אבל לאחר מכן ההקצאה של a[3]
גורמת להמרה מחדש של המערך למערך שיכול להכיל ערכים כלשהם (מספרים או אובייקטים). במקרה השני, למהדרר ידועים הסוגים של כל הרכיבים בביטוי הללי, וניתן לקבוע מראש את הכיתה המוסתרת.
- איך מאתחלים מערכים קטנים בגודל קבוע באמצעות ליטרלים של מערכים
- הקצאה מראש של מערכי נתונים קטנים (פחות מ-64k) בגודל הנכון לפני השימוש בהם
- לא לשמור ערכים לא מספריים (אובייקטים) במערכים מספריים
- אם אתם מבצעים את האיניציאליזציה ללא לטרלים, חשוב להיזהר שלא תגרמו להמרה מחדש של מערכי נתונים קטנים.
הידור JavaScript
JavaScript היא שפה דינמית מאוד, והטמעות המקוריות שלה היו מתורגמים, אבל במנועי JavaScript מודרניים בסביבת זמן ריצה נעשה שימוש בתהליך הידור. למעשה, ל-V8 (JavaScript של Chrome) יש שני מהדרי JIT שונים:
- המהדרר 'מלא', שיכול ליצור קוד טוב לכל JavaScript
- המהדר האופטימיזציה, שמייצר קוד מצוין לרוב ה-JavaScript, אבל זמן הדרכה ארוך יותר.
The Full Compiler
ב-V8, המהדר Full פועל בכל הקוד ומתחיל להריץ קוד בהקדם האפשרי, ויוצר במהירות קוד טוב אבל לא מצוין. המהדר הזה לא מניח כמעט כלום לגבי סוגים בזמן הידור – הוא מצפה שסוגי המשתנים ישתנו ויכולים להשתנות במהלך זמן הריצה. הקוד שנוצר על ידי המהדר Full משתמש במטמון מוטמע (IC) כדי לשפר את הידע לגבי הטיפוסים בזמן הריצה של התוכנית, וכך לשפר את היעילות בזמן אמת.
המטרה של מטמון מוטמע היא לטפל בסוגי נתונים ביעילות, על ידי שמירת קוד תלוי-סוג במטמון לפעולות. כשהקוד פועל, הוא מאמת קודם את הנחות הסוג, ואז משתמש במטמון המוטמע כדי לקצר את הפעולה. עם זאת, המשמעות היא שהביצועים של פעולות שמקבלות כמה סוגים יהיו נמוכים יותר.
לכן
- עדיף להשתמש בפעולות מונומורפיות במקום בפעולות פולימורפיות
פעולות הן מונומורפיות אם הכיתות המוסתרות של הקלט הן תמיד זהות – אחרת הן פולימורפיות, כלומר חלק מהארגומנטים יכולים לשנות את הסוג בהתאם לקריאות שונות לפעולה. לדוגמה, הקריאה השנייה של add() בדוגמה הזו גורמת לפולימורפיזם:
function add(x, y) {
return x + y;
}
add(1, 2); // + in add is monomorphic
add("a", "b"); // + in add becomes polymorphic```
ה-Optimizing Compiler
במקביל למהדר המלא, V8 מקמפל מחדש פונקציות 'חמות' (כלומר פונקציות שפועלות פעמים רבות) באמצעות מהדר אופטימיזציה. המהדר הזה משתמש במשוב על סוגי נתונים כדי להאיץ את הקוד המהודר – למעשה, הוא משתמש בסוגי הנתונים שנלקחו מהמעגלים המשולבים שדיברנו עליהם מקודם!
במהלך הידור האופטימיזציה, הפעולות מוטמעות באופן ספקולטיבי (ממוקמות ישירות במקום שבו הן נקראות). הפעולה הזו מזרזת את הביצוע (על חשבון טביעת הרגל בזיכרון), אבל גם מאפשרת אופטימיזציות אחרות. אפשר להטמיע בקוד (inline) באופן מלא פונקציות ו-constructors מונומורפיים (זו סיבה נוספת לכך שמונומורפיזם הוא רעיון טוב ב-V8).
אפשר לתעד את הנתונים שעוברים אופטימיזציה באמצעות הגרסה העצמאית 'd8' של מנוע V8:
d8 --trace-opt primes.js
(הפעולה הזו מתעדת את השמות של הפונקציות שעברו אופטימיזציה ב-stdout).
עם זאת, לא ניתן לבצע אופטימיזציה של כל הפונקציות – יש תכונות שמונעות מהמחשבר לבצע אופטימיזציה של פונקציה מסוימת ('יציאה'). באופן ספציפי, המהדרר שמופעלת בו אופטימיזציה יוצא כרגע משימוש בפונקציות עם בלוקים מסוג try {} catch {}.
לכן
- אם יש לכם בלוקים מסוג try {} catch {}, כדאי להכניס קוד רגיש לביצועים לפונקציה בתצוגת עץ: ```js function perf_sensitive() { // Do performance-sensitive work here }
try { perf_sensitive() } catch (e) { // Handle exceptions here } ```
ההנחיה הזו צפויה להשתנות בעתיד, כי אנחנו מפעילים ב-compiler לבצע אופטימיזציה בלוקים של try/catch. אפשר לבדוק איך המהדר האופטימיזציה יוצא מהפונקציות באמצעות האפשרות '--trace-opt' עם d8 כמו למעלה, שמספקת מידע נוסף על הפונקציות שהמהדר יוצא מהן:
d8 --trace-opt primes.js
ביטול אופטימיזציה
לבסוף, האופטימיזציה שמתבצעת על ידי המהדר הזה היא ספקולטיבית – לפעמים היא לא מצליחה ואנחנו חוזרים אחורה. בתהליך 'ביטול האופטימיזציה', הקוד המותאם מבוטל וההרצה ממשיכה במקום הנכון בקוד המלא של המהדר. יכול להיות שהאופטימיזציה מחדש תופעל שוב מאוחר יותר, אבל לטווח הקצר, הביצועים יתעכבו. באופן ספציפי, ביצוע שינויים בקטגוריות המוסתרות של המשתנים אחרי ביצוע האופטימיזציה של הפונקציות יוביל לביטול האופטימיזציה.
לכן
- הימנעות משינויים מוסתרים של סיווגים בפונקציות אחרי שהן עברו אופטימיזציה
כמו באופטימיזציות אחרות, אפשר לקבל יומן של פונקציות ש-V8 נאלץ לבטל את האופטימיזציה שלהן באמצעות דגל לתיעוד ביומן:
d8 --trace-deopt primes.js
כלים אחרים של V8
דרך אגב, אפשר גם להעביר ל-Chrome אפשרויות מעקב V8 בזמן ההפעלה:
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --js-flags="--trace-opt --trace-deopt"```
בנוסף ליצירת פרופיל באמצעות הכלים למפתחים, אפשר גם להשתמש ב-d8 כדי ליצור פרופיל:
% out/ia32.release/d8 primes.js --prof
לשם כך נעשה שימוש בפרופילר הדגימה המובנה, שמבצע דגימה בכל אלפית שנייה וכותב את הקובץ v8.log.
לסיכום
כדי להכין את עצמכם ליצירת JavaScript עם ביצועים טובים, חשוב לזהות ולהבין איך מנוע V8 פועל עם הקוד שלכם. שוב, העצה הבסיסית היא:
- הכנה מראש לפני שמתעוררת בעיה (או שמבחינים בה)
- לאחר מכן, מזהים את הבעיה ומבינים מהו הליבה שלה
- ולבסוף, מתקנים את מה שחשוב
כלומר, קודם צריך לוודא שהבעיה נובעת מ-JavaScript, באמצעות כלים אחרים כמו PageSpeed. יכול להיות שצריך לצמצם את הקוד ל-JavaScript טהור (ללא DOM) לפני איסוף המדדים, ואז להשתמש במדדים האלה כדי לאתר צווארי בקבוק ולחסל את החשובים שבהם. אני מקווה שההרצאה של דניאל (והמאמר הזה) יעזרו לכם להבין טוב יותר איך V8 מפעיל JavaScript – אבל חשוב גם להתמקד באופטימיזציה של האלגוריתמים שלכם.