מבוא
דניאל קליפורד (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 ביט.
מערכי נתונים
כדי לטפל במערכים גדולים וחלשים, יש שני סוגים של אחסון מערכים באופן פנימי:
- אלמנטים מהירים: אחסון ליניארי לקבוצות של מפתחות קומפקטיים
- רכיבי מילון: גיבוב (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.
}
בנוסף, מערכי מספרים כפולים (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 מקמפל מחדש פונקציות 'חמות' (כלומר פונקציות שפועלות הרבה פעמים) באמצעות מהדר אופטימיזציה. המהדר הזה משתמש במשוב מסוג כדי להפוך את הקוד המורכב למהיר יותר. למעשה, הוא משתמש בסוגים שנלקחו מרכיבי ה-IC שעליהם דיברנו!
במהלך הידור האופטימיזציה, הפעולות מוטמעות באופן ספקולטיבי (ממוקמות ישירות במקום שבו הן נקראות). הפעולה הזו מזרזת את הביצוע (על חשבון טביעת הרגל בזיכרון), אבל גם מאפשרת לבצע אופטימיזציות אחרות. אפשר להטמיע בקוד (inline) באופן מלא פונקציות ו-constructors מונומורפיים (זו סיבה נוספת לכך שמונומורפיזם הוא רעיון טוב ב-V8).
אפשר לתעד את הנתונים שעוברים אופטימיזציה באמצעות הגרסה העצמאית 'd8' של מנוע V8:
d8 --trace-opt primes.js
(הפעולה הזו מתעדת את השמות של הפונקציות שעברו אופטימיזציה ב-stdout).
עם זאת, לא ניתן לבצע אופטימיזציה של כל הפונקציות – תכונות מסוימות מונעות מהמחשבר לבצע אופטימיזציה של פונקציה מסוימת ('יציאה'). באופן ספציפי, המהדרר שמבצע אופטימיזציה יוצא כרגע משימוש בפונקציות עם בלוקים מסוג try {} catch {}.
לכן
- אם יש לכם בלוקים מסוג try {} catch {}, כדאי להכניס קוד שמושפע מהביצועים לפונקציה בתצוגת עץ: ```js function perf_sensitive() { // כאן מבצעים עבודה שמשפיעה על הביצועים }
Try { perf_sensitive() } catch (e) { // Handleexcept here } ```
ההנחיות האלה כנראה ישתנו בעתיד, מכיוון שנפעיל בלוקים מסוג Try/catch עם המהדר (compiler) לאופטימיזציה. אפשר לבדוק איך המהדר האופטימיזציה יוצא מהפונקציות באמצעות האפשרות '--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.
לסיכום
חשוב לזהות ולהבין איך מנוע V8 פועל עם הקוד שלכם כדי להתכונן ליצירת JavaScript עם ביצועים טובים. שוב, העצה הבסיסית היא:
- הכנה מראש לפני שמתעוררת בעיה (או שמבחינים בה)
- לאחר מכן, מזהים את הבעיה ומבינים מהו הליבה שלה
- ולבסוף, מתקנים את מה שחשוב
כלומר, צריך לוודא שהבעיה היא ב-JavaScript, על ידי שימוש קודם בכלים אחרים כמו PageSpeed; אפשר גם להקטין ל-JavaScript טהור (ללא DOM) לפני איסוף מדדים, ולאחר מכן להשתמש במדדים האלה כדי לאתר צווארי בקבוק ולהסיר את החשובים החשובים. אני מקווה שהשיחה של דניאל (והמאמר הזה) יעזרו לכם להבין טוב יותר איך V8 מריץ JavaScript. אל תשכחו להתמקד גם באופטימיזציה של אלגוריתמים משלכם.